Files
stack/apps/gateway/src/commands/command-executor.service.ts
jason.woltje 0d12471868
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348)
2026-04-02 18:08:30 +00:00

587 lines
18 KiB
TypeScript

import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue';
import type { Brain } from '@mosaic/brain';
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 { 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<SlashCommandResultPayload> {
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<SlashCommandResultPayload> {
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 <name> to change or /model clear to reset.`,
};
}
return {
command: 'model',
conversationId,
success: true,
message:
'Usage: /model <model-name> — 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<SlashCommandResultPayload> {
const level = args?.toLowerCase();
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
return {
command: 'thinking',
conversationId,
success: true,
message: 'Usage: /thinking <none|low|medium|high|auto>',
};
}
return {
command: 'thinking',
conversationId,
success: true,
message: `Thinking level set to "${level}".`,
};
}
private async handleSystem(
args: string | null,
conversationId: string,
): Promise<SlashCommandResultPayload> {
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<SlashCommandResultPayload> {
if (!args) {
return {
command: 'agent',
success: true,
message:
'Usage: /agent <agent-id> | /agent list | /agent new <name> 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 <name> — 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 <name> — 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<SlashCommandResultPayload> {
if (!args) {
return {
command: 'provider',
success: true,
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
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 <provider-name>',
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 <provider-name>',
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<SlashCommandResultPayload> {
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 <id>|list|tasks]',
conversationId,
};
}
private async handleTools(
conversationId: string,
_userId: string,
): Promise<SlashCommandResultPayload> {
// 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<SlashCommandResultPayload> {
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 <server-name>',
};
}
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 <name>`,
};
}
}
}