import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common'; import type { QueueHandle } from '@mosaic/queue'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/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 { 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'], @Optional() @Inject(forwardRef(() => ReloadService)) private readonly reloadService: ReloadService | null, @Optional() @Inject(forwardRef(() => ChatGateway)) private readonly chatGateway: ChatGateway | 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': { // User-scoped sweep for non-admin; system-wide for admin const result = await this.sessionGC.sweepOrphans(userId); 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); 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 '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) { return { command: 'model', conversationId, success: true, message: 'Usage: /model ', }; } // Update agent session model if session is active // For now, acknowledge the request — full wiring done in P8-012 const session = this.agentService.getSession(conversationId); if (!session) { return { command: 'model', conversationId, success: true, message: `Model switch to "${args}" requested. No active session for this conversation.`, }; } return { command: 'model', conversationId, success: true, message: `Model switch to "${args}" requested.`, }; } 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, ): Promise { if (!args) { return { command: 'agent', success: true, message: 'Usage: /agent to switch, or /agent list to see available agents.', conversationId, }; } if (args === 'list') { return { command: 'agent', success: true, message: 'Agent listing: use the web dashboard for full agent management.', conversationId, }; } // Switch agent — stub for now (full implementation in P8-015) return { command: 'agent', success: true, message: `Agent switch to "${args}" requested. Restart conversation to apply.`, 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, }; } }