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 type { ActorTenantScope } from '../auth/session-scope.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 { CommandAuthorizationService } from './command-authorization.service.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, @Optional() @Inject(CommandAuthorizationService) private readonly authorization: CommandAuthorizationService | null = null, ) {} async execute( payload: SlashCommandPayload, scope: ActorTenantScope, ): Promise { const { command, args, conversationId } = payload; const userId = scope.userId; const def = this.registry.getManifest().commands.find((c) => c.name === command); if (!def) { return { command, conversationId, success: false, message: `Unknown command: /${command}`, }; } const authorization = await this.authorization?.authorize( def, payload, userId, payload.approvalId, ); if (authorization && !authorization.allowed) { return { command, conversationId, success: false, message: authorization.reason }; } try { switch (command) { case 'model': return await this.handleModel(args ?? null, conversationId, scope); case 'thinking': return await this.handleThinking(args ?? null, conversationId); case 'system': return await this.handleSystem(args ?? null, conversationId, scope); 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': // Global retention requires a separate, authorized and audited job. // Session cleanup is performed only through the session lifecycle. return { command: 'gc', success: false, message: 'Global GC is disabled pending an authorized retention job.', conversationId, }; case 'agent': return await this.handleAgent(args ?? null, conversationId, scope); 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) }; } } async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) { const def = this.registry .getManifest() .commands.find((command) => command.name === payload.command); if (!def || !this.authorization) return null; return this.authorization.createApproval(def, payload, scope.userId); } private async handleModel( args: string | null, conversationId: string, scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Show current override or usage hint const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope); 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, scope); 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, scope); const session = this.agentService.getSession(conversationId, scope); 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, scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Clear the override when called with no args await this.systemOverride.clear(conversationId, scope); return { command: 'system', conversationId, success: true, message: 'Session system prompt override cleared.', }; } await this.systemOverride.set(conversationId, args.trim(), scope); 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, scope: ActorTenantScope, ): Promise { const userId = scope.userId; 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, scope, agentConfig.model ?? undefined, ); // Broadcast updated session:info so TUI TopBar reflects new agent/model this.chatGateway?.broadcastSessionInfo(conversationId, scope, { 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 tokenDigest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(pollToken), ); const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => byte.toString(16).padStart(2, '0'), ).join(''); const key = `mosaic:auth:poll:${tokenHash}`; // Persist only a short-lived token digest. The raw token is delivered only by // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); return { command: 'provider', success: true, message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, data: { 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 `, }; } } }