import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { Brain } from '@mosaicstack/brain'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types'; import { AgentService } from '../agent/agent.service.js'; import { ChatGateway } from '../chat/chat.gateway.js'; import { SessionGCService } from '../gc/session-gc.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { ReloadService } from '../reload/reload.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; import { CommandRegistryService } from './command-registry.service.js'; @Injectable() export class CommandExecutorService { private readonly logger = new Logger(CommandExecutorService.name); constructor( @Inject(CommandRegistryService) private readonly registry: CommandRegistryService, @Inject(AgentService) private readonly agentService: AgentService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'], @Inject(BRAIN) private readonly brain: Brain, @Optional() @Inject(forwardRef(() => ReloadService)) private readonly reloadService: ReloadService | null, @Optional() @Inject(forwardRef(() => ChatGateway)) private readonly chatGateway: ChatGateway | null, @Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService | null, ) {} async execute(payload: SlashCommandPayload, userId: string): Promise { const { command, args, conversationId } = payload; const def = this.registry.getManifest().commands.find((c) => c.name === command); if (!def) { return { command, conversationId, success: false, message: `Unknown command: /${command}`, }; } try { switch (command) { case 'model': return await this.handleModel(args ?? null, conversationId); case 'thinking': return await this.handleThinking(args ?? null, conversationId); case 'system': return await this.handleSystem(args ?? null, conversationId); case 'new': return { command, conversationId, success: true, message: 'Start a new conversation by selecting New Conversation.', }; case 'clear': return { command, conversationId, success: true, message: 'Conversation display cleared.', }; case 'compact': return { command, conversationId, success: true, message: 'Context compaction requested.', }; case 'retry': return { command, conversationId, success: true, message: 'Retry last message requested.', }; case 'gc': { // Admin-only: system-wide GC sweep across all sessions const result = await this.sessionGC.sweepOrphans(); return { command: 'gc', success: true, message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`, conversationId, }; } case 'agent': return await this.handleAgent(args ?? null, conversationId, userId); case 'provider': return await this.handleProvider(args ?? null, userId, conversationId); case 'mission': return await this.handleMission(args ?? null, conversationId, userId); case 'prdy': return { command: 'prdy', success: true, message: 'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.', conversationId, }; case 'tools': return await this.handleTools(conversationId, userId); case 'mcp': return await this.handleMcp(args ?? null, conversationId); case 'reload': { if (!this.reloadService) { return { command: 'reload', conversationId, success: false, message: 'ReloadService is not available.', }; } const reloadResult = await this.reloadService.reload('command'); this.chatGateway?.broadcastReload(reloadResult); return { command: 'reload', success: true, message: reloadResult.message, conversationId, }; } default: return { command, conversationId, success: false, message: `Command /${command} is not yet implemented.`, }; } } catch (err) { this.logger.error(`Command /${command} failed: ${err}`); return { command, conversationId, success: false, message: String(err) }; } } private async handleModel( args: string | null, conversationId: string, ): Promise { if (!args || args.trim().length === 0) { // Show current override or usage hint const currentOverride = this.chatGateway?.getModelOverride(conversationId); if (currentOverride) { return { command: 'model', conversationId, success: true, message: `Current model override: "${currentOverride}". Use /model to change or /model clear to reset.`, }; } return { command: 'model', conversationId, success: true, message: 'Usage: /model — sets a per-session model override (bypasses routing). Use /model clear to reset.', }; } const modelName = args.trim(); // /model clear removes the override and re-enables automatic routing if (modelName === 'clear') { this.chatGateway?.setModelOverride(conversationId, null); return { command: 'model', conversationId, success: true, message: 'Model override cleared. Automatic routing will be used for new sessions.', }; } // Set the sticky per-session override (M4-007) this.chatGateway?.setModelOverride(conversationId, modelName); const session = this.agentService.getSession(conversationId); if (!session) { return { command: 'model', conversationId, success: true, message: `Model override set to "${modelName}". Will apply when a new session starts for this conversation.`, }; } return { command: 'model', conversationId, success: true, message: `Model override set to "${modelName}". The override is active for this conversation and will be used on the next message if a new session is needed.`, }; } private async handleThinking( args: string | null, conversationId: string, ): Promise { const level = args?.toLowerCase(); if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) { return { command: 'thinking', conversationId, success: true, message: 'Usage: /thinking ', }; } return { command: 'thinking', conversationId, success: true, message: `Thinking level set to "${level}".`, }; } private async handleSystem( args: string | null, conversationId: string, ): Promise { if (!args || args.trim().length === 0) { // Clear the override when called with no args await this.systemOverride.clear(conversationId); return { command: 'system', conversationId, success: true, message: 'Session system prompt override cleared.', }; } await this.systemOverride.set(conversationId, args.trim()); return { command: 'system', conversationId, success: true, message: `Session system prompt override set (expires in 5 minutes of inactivity).`, }; } private async handleAgent( args: string | null, conversationId: string, userId: string, ): Promise { if (!args) { return { command: 'agent', success: true, message: 'Usage: /agent | /agent list | /agent new to create a new agent.', conversationId, }; } if (args === 'list') { return { command: 'agent', success: true, message: 'Agent listing: use the web dashboard for full agent management.', conversationId, }; } // M5-006: /agent new — create a new agent config via brain.agents.create() if (args.startsWith('new')) { const namePart = args.slice(3).trim(); if (!namePart) { return { command: 'agent', success: false, message: 'Usage: /agent new — provide a name for the new agent.', conversationId, }; } try { const defaultProvider = process.env['DEFAULT_PROVIDER'] ?? 'anthropic'; const defaultModel = process.env['DEFAULT_MODEL'] ?? 'claude-sonnet-4-5-20251001'; const newAgent = await this.brain.agents.create({ name: namePart, provider: defaultProvider, model: defaultModel, status: 'idle', ownerId: userId, isSystem: false, }); this.logger.log(`Created new agent "${newAgent.name}" (${newAgent.id}) for user ${userId}`); return { command: 'agent', success: true, message: `Agent "${newAgent.name}" created with ID: ${newAgent.id}. Configure it via the web dashboard.`, conversationId, data: { agentId: newAgent.id, agentName: newAgent.name }, }; } catch (err) { this.logger.error(`Failed to create agent: ${err}`); return { command: 'agent', success: false, message: `Failed to create agent: ${String(err)}`, conversationId, }; } } // M5-003: Look up agent by name (or ID) and apply to session mid-conversation const agentName = args.trim(); try { // Try lookup by name first; fall back to ID-based lookup let agentConfig = await this.brain.agents.findByName(agentName); if (!agentConfig) { // Try by ID (UUID-style input) agentConfig = await this.brain.agents.findById(agentName); } if (!agentConfig) { return { command: 'agent', success: false, message: `Agent "${agentName}" not found. Use /agent list to see available agents.`, conversationId, }; } // Apply the agent config to the live session and emit session:info (M5-003) this.agentService.applyAgentConfig( conversationId, agentConfig.id, agentConfig.name, agentConfig.model ?? undefined, ); // Broadcast updated session:info so TUI TopBar reflects new agent/model this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name }); this.logger.log( `Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`, ); return { command: 'agent', success: true, message: `Switched to agent "${agentConfig.name}". System prompt and tools applied. Model: ${agentConfig.model ?? 'default'}.`, conversationId, data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model }, }; } catch (err) { this.logger.error(`Failed to switch agent "${agentName}": ${err}`); return { command: 'agent', success: false, message: `Failed to switch agent: ${String(err)}`, conversationId, }; } } private async handleProvider( args: string | null, userId: string, conversationId: string, ): Promise { if (!args) { return { command: 'provider', success: true, message: 'Usage: /provider list | /provider login | /provider logout ', conversationId, }; } const spaceIdx = args.indexOf(' '); const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args; const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : ''; switch (subcommand) { case 'list': return { command: 'provider', success: true, message: 'Use the web dashboard to manage providers.', conversationId, }; case 'login': { if (!providerName) { return { command: 'provider', success: false, message: 'Usage: /provider login ', conversationId, }; } const pollToken = crypto.randomUUID(); const key = `mosaic:auth:poll:${pollToken}`; // Store pending state in Valkey (TTL 5 minutes) await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); // In production this would construct an OAuth URL const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, conversationId, data: { loginUrl, pollToken, provider: providerName }, }; } case 'logout': { if (!providerName) { return { command: 'provider', success: false, message: 'Usage: /provider logout ', conversationId, }; } return { command: 'provider', success: true, message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`, conversationId, }; } default: return { command: 'provider', success: false, message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`, conversationId, }; } } private async handleMission( args: string | null, conversationId: string, _userId: string, ): Promise { if (!args || args === 'status') { // TODO: fetch active mission from DB when MissionsService is available return { command: 'mission', success: true, message: 'Mission status: use the web dashboard for full mission management.', conversationId, }; } if (args.startsWith('set ')) { const missionId = args.slice(4).trim(); return { command: 'mission', success: true, message: `Mission set to ${missionId}. Session context updated.`, conversationId, }; } return { command: 'mission', success: true, message: 'Usage: /mission [status|set |list|tasks]', conversationId, }; } private async handleTools( conversationId: string, _userId: string, ): Promise { // TODO: fetch tool list from active agent session return { command: 'tools', success: true, message: 'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.', conversationId, }; } private async handleMcp( args: string | null, conversationId: string, ): Promise { if (!this.mcpClient) { return { command: 'mcp', conversationId, success: false, message: 'MCP client service is not available.', }; } const action = args?.trim().split(/\s+/)[0] ?? 'status'; switch (action) { case 'status': case 'servers': { const statuses = this.mcpClient.getServerStatuses(); if (statuses.length === 0) { return { command: 'mcp', conversationId, success: true, message: 'No MCP servers configured. Set MCP_SERVERS env var to connect external tool servers.', }; } const lines = ['MCP Server Status:\n']; for (const s of statuses) { const status = s.connected ? '✓ connected' : '✗ disconnected'; lines.push(` ${s.name}: ${status}`); lines.push(` URL: ${s.url}`); lines.push(` Tools: ${s.toolCount}`); if (s.error) lines.push(` Error: ${s.error}`); lines.push(''); } const tools = this.mcpClient.getToolDefinitions(); if (tools.length > 0) { lines.push(`Total bridged tools: ${tools.length}`); lines.push(`Tool names: ${tools.map((t) => t.name).join(', ')}`); } return { command: 'mcp', conversationId, success: true, message: lines.join('\n'), }; } case 'reconnect': { const serverName = args?.trim().split(/\s+/).slice(1).join(' '); if (!serverName) { return { command: 'mcp', conversationId, success: false, message: 'Usage: /mcp reconnect ', }; } try { await this.mcpClient.reconnectServer(serverName); return { command: 'mcp', conversationId, success: true, message: `MCP server "${serverName}" reconnected successfully.`, }; } catch (err) { return { command: 'mcp', conversationId, success: false, message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`, }; } } default: return { command: 'mcp', conversationId, success: false, message: `Unknown MCP action: "${action}". Use: /mcp status, /mcp servers, /mcp reconnect `, }; } } }