diff --git a/app/api/chat/[project_id]/act/route.ts b/app/api/chat/[project_id]/act/route.ts
index c5395c04..4f34ef76 100644
--- a/app/api/chat/[project_id]/act/route.ts
+++ b/app/api/chat/[project_id]/act/route.ts
@@ -15,6 +15,7 @@ import { initializeNextJsProject as initializeCodexProject, applyChanges as appl
import { initializeNextJsProject as initializeCursorProject, applyChanges as applyCursorChanges } from '@/lib/services/cli/cursor';
import { initializeNextJsProject as initializeQwenProject, applyChanges as applyQwenChanges } from '@/lib/services/cli/qwen';
import { initializeNextJsProject as initializeGLMProject, applyChanges as applyGLMChanges } from '@/lib/services/cli/glm';
+import { initializeNextJsProject as initializeMiniMaxProject, applyChanges as applyMiniMaxChanges } from '@/lib/services/cli/minimax';
import { getDefaultModelForCli, normalizeModelId } from '@/lib/constants/cliModels';
import { streamManager } from '@/lib/services/stream';
import type { ChatActRequest } from '@/types/backend';
@@ -407,6 +408,8 @@ export async function POST(request: NextRequest, { params }: RouteContext) {
? initializeQwenProject
: cliPreference === 'glm'
? initializeGLMProject
+ : cliPreference === 'minimax'
+ ? initializeMiniMaxProject
: initializeClaudeProject;
executor(
@@ -428,6 +431,8 @@ export async function POST(request: NextRequest, { params }: RouteContext) {
? applyQwenChanges
: cliPreference === 'glm'
? applyGLMChanges
+ : cliPreference === 'minimax'
+ ? applyMiniMaxChanges
: applyClaudeChanges;
const sessionId =
diff --git a/app/api/settings/cli-status/route.ts b/app/api/settings/cli-status/route.ts
index 35022bb8..4693414b 100644
--- a/app/api/settings/cli-status/route.ts
+++ b/app/api/settings/cli-status/route.ts
@@ -10,6 +10,7 @@ import type { CLIStatus } from '@/types/backend';
import { CODEX_MODEL_DEFINITIONS } from '@/lib/constants/codexModels';
import { QWEN_MODEL_DEFINITIONS } from '@/lib/constants/qwenModels';
import { GLM_MODEL_DEFINITIONS } from '@/lib/constants/glmModels';
+import { MINIMAX_MODEL_DEFINITIONS } from '@/lib/constants/minimaxModels';
import { CURSOR_MODEL_DEFINITIONS } from '@/lib/constants/cursorModels';
const execAsync = promisify(exec);
@@ -132,6 +133,10 @@ export async function GET() {
installed: false,
checking: false,
},
+ minimax: {
+ installed: false,
+ checking: false,
+ },
};
// Check Claude Code CLI installation
@@ -179,6 +184,16 @@ export async function GET() {
models: GLM_MODEL_DEFINITIONS.map((model) => model.id),
};
+ // MiniMax reuses the Claude Code runtime (Anthropic-compatible endpoint)
+ const minimaxStatus = claudeStatus;
+ status.minimax = {
+ installed: minimaxStatus.installed,
+ version: minimaxStatus.version,
+ checking: false,
+ error: minimaxStatus.error,
+ models: MINIMAX_MODEL_DEFINITIONS.map((model) => model.id),
+ };
+
return NextResponse.json(status);
} catch (error) {
console.error('[API] Failed to check CLI status:', error);
diff --git a/components/modals/CreateProjectModal.tsx b/components/modals/CreateProjectModal.tsx
index 271c46db..3764d499 100644
--- a/components/modals/CreateProjectModal.tsx
+++ b/components/modals/CreateProjectModal.tsx
@@ -98,6 +98,22 @@ const CLI_OPTIONS: CLIOption[] = [
})),
features: ['Claude-compatible runtime', 'GLM 4.6 reasoning', 'Text-only mode'],
},
+ {
+ id: 'minimax',
+ name: 'MiniMax CLI',
+ icon: '🟣',
+ description: 'MiniMax agent running via Claude Code runtime',
+ color: 'from-purple-500 to-fuchsia-600',
+ downloadUrl: 'https://platform.minimax.io/docs',
+ installCommand: 'npm install -g @anthropic-ai/claude-code',
+ models: getModelDefinitionsForCli('minimax').map(({ id, name, description, supportsImages }) => ({
+ id,
+ name,
+ description,
+ supportsImages,
+ })),
+ features: ['Claude-compatible runtime', 'MiniMax M3 reasoning', 'Multimodal input'],
+ },
];
function generateUUID() {
diff --git a/components/settings/GlobalSettings.tsx b/components/settings/GlobalSettings.tsx
index 6ec1b69c..8f574a66 100644
--- a/components/settings/GlobalSettings.tsx
+++ b/components/settings/GlobalSettings.tsx
@@ -92,6 +92,18 @@ const CLI_OPTIONS: CLIOption[] = [
enabled: true,
models: getModelDefinitionsForCli('glm').map(({ id, name }) => ({ id, name })),
},
+ {
+ id: 'minimax',
+ name: 'MiniMax CLI',
+ icon: '',
+ description: 'MiniMax agent running through Claude Code runtime',
+ color: 'from-purple-500 to-fuchsia-600',
+ brandColor: '#FF2E5F',
+ downloadUrl: 'https://platform.minimax.io/docs',
+ installCommand: 'npm install -g @anthropic-ai/claude-code',
+ enabled: true,
+ models: getModelDefinitionsForCli('minimax').map(({ id, name }) => ({ id, name })),
+ },
];
// Global settings are provided by context
@@ -544,6 +556,11 @@ export default function GlobalSettings({ isOpen, onClose, initialTab = 'general'
{cli.id === 'glm' && (
)}
+ {cli.id === 'minimax' && (
+
+ MM
+
+ )}
{cli.id === 'gemini' && (
)}
@@ -641,6 +658,38 @@ export default function GlobalSettings({ isOpen, onClose, initialTab = 'general'
)}
+ {cli.id === 'minimax' && (
+
+
+ API Key
+
+
+ setCliApiKey(cli.id, e.target.value)}
+ placeholder="Enter MiniMax API key"
+ className="flex-1 px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200"
+ />
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ toggleApiKeyVisibility(cli.id);
+ }}
+ className="px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg bg-white transition-colors"
+ >
+ {apiKeyVisibility[cli.id] ? 'Hide' : 'Show'}
+
+
+
+ Stored locally and injected as MINIMAX_API_KEY when running MiniMax.
+ Set MINIMAX_REGION to cn_zh to use the China endpoint.
+ Leave blank to rely on server environment variables instead.
+
+
+ )}
) : (
e.stopPropagation()}>
@@ -918,6 +967,7 @@ export default function GlobalSettings({ isOpen, onClose, initialTab = 'general'
{selectedCLI.id === 'gemini' && 'Authenticate (OAuth or API Key)'}
{selectedCLI.id === 'glm' && 'Authenticate (Z.ai DevPack login)'}
+ {selectedCLI.id === 'minimax' && 'Authenticate (MiniMax API key)'}
{selectedCLI.id === 'qwen' && 'Authenticate (Qwen OAuth or API Key)'}
{selectedCLI.id === 'codex' && 'Start Codex and sign in'}
{selectedCLI.id === 'claude' && 'Start Claude and sign in'}
@@ -930,6 +980,7 @@ export default function GlobalSettings({ isOpen, onClose, initialTab = 'general'
selectedCLI.id === 'codex' ? 'codex' :
selectedCLI.id === 'qwen' ? 'qwen' :
selectedCLI.id === 'glm' ? 'zai' :
+ selectedCLI.id === 'minimax' ? 'claude' :
selectedCLI.id === 'gemini' ? 'gemini' : ''}
= {
gemini: 'gemini-2.5-pro',
qwen: QWEN_DEFAULT_MODEL,
glm: GLM_DEFAULT_MODEL,
+ minimax: MINIMAX_DEFAULT_MODEL,
};
const MODEL_DEFINITIONS: Record = {
@@ -33,6 +35,7 @@ const MODEL_DEFINITIONS: Record = {
],
qwen: QWEN_MODEL_DEFINITIONS,
glm: GLM_MODEL_DEFINITIONS,
+ minimax: MINIMAX_MODEL_DEFINITIONS,
};
export function getDefaultModelForCli(cli: string | null | undefined): string {
@@ -56,6 +59,8 @@ export function normalizeModelId(cli: string | null | undefined, model?: string
return normalizeQwenModelId(model);
case 'glm':
return normalizeGLMModelId(model);
+ case 'minimax':
+ return normalizeMiniMaxModelId(model);
case 'claude':
default:
return normalizeClaudeModelId(model);
@@ -76,6 +81,8 @@ export function getModelDisplayName(cli: string | null | undefined, modelId?: st
return getQwenModelDisplayName(modelId);
case 'glm':
return getGLMModelDisplayName(modelId);
+ case 'minimax':
+ return getMiniMaxModelDisplayName(modelId);
case 'claude':
default:
return getClaudeModelDisplayName(normalizeClaudeModelId(modelId));
diff --git a/lib/constants/minimaxModels.ts b/lib/constants/minimaxModels.ts
new file mode 100644
index 00000000..868e6ab7
--- /dev/null
+++ b/lib/constants/minimaxModels.ts
@@ -0,0 +1,87 @@
+export type MiniMaxModelId = 'MiniMax-M3' | 'MiniMax-M2.7';
+
+export interface MiniMaxModelDefinition {
+ id: MiniMaxModelId;
+ /** User facing display name */
+ name: string;
+ /** Longer description shown in pickers */
+ description?: string;
+ /** Whether the model accepts image input */
+ supportsImages?: boolean;
+ /** Alias strings that should resolve to this model */
+ aliases: string[];
+}
+
+export const MINIMAX_MODEL_DEFINITIONS: MiniMaxModelDefinition[] = [
+ {
+ id: 'MiniMax-M3',
+ name: 'MiniMax M3',
+ description: 'MiniMax M3 with 1M context, multimodal input and Claude Code compatible agent runtime',
+ supportsImages: true,
+ aliases: [
+ 'minimax-m3',
+ 'minimaxm3',
+ 'minimax m3',
+ 'minimax_m3',
+ 'm3',
+ 'minimax-latest',
+ 'minimax',
+ ],
+ },
+ {
+ id: 'MiniMax-M2.7',
+ name: 'MiniMax M2.7',
+ description: 'MiniMax M2.7 text model with 204k context and always-on thinking',
+ supportsImages: false,
+ aliases: [
+ 'minimax-m2.7',
+ 'minimaxm2.7',
+ 'minimax m2.7',
+ 'minimax_m2_7',
+ 'm2.7',
+ 'm27',
+ ],
+ },
+];
+
+export const MINIMAX_DEFAULT_MODEL: MiniMaxModelId = 'MiniMax-M3';
+
+const MINIMAX_MODEL_ALIAS_MAP: Record = MINIMAX_MODEL_DEFINITIONS.reduce(
+ (map, definition) => {
+ definition.aliases.forEach((alias) => {
+ const key = alias.trim().toLowerCase();
+ map[key] = definition.id;
+ });
+ map[definition.id.toLowerCase()] = definition.id;
+ return map;
+ },
+ {} as Record,
+);
+
+export function normalizeMiniMaxModelId(model?: string | null): MiniMaxModelId {
+ if (!model) {
+ return MINIMAX_DEFAULT_MODEL;
+ }
+ const normalized = model.trim().toLowerCase();
+ if (!normalized) {
+ return MINIMAX_DEFAULT_MODEL;
+ }
+ return MINIMAX_MODEL_ALIAS_MAP[normalized] ?? MINIMAX_DEFAULT_MODEL;
+}
+
+export function getMiniMaxModelDefinition(id: string): MiniMaxModelDefinition | undefined {
+ return (
+ MINIMAX_MODEL_DEFINITIONS.find((definition) => definition.id === id) ??
+ MINIMAX_MODEL_DEFINITIONS.find((definition) =>
+ definition.aliases.some((alias) => alias.toLowerCase() === id.toLowerCase()),
+ )
+ );
+}
+
+export function getMiniMaxModelDisplayName(id?: string | null): string {
+ if (!id) {
+ return getMiniMaxModelDefinition(MINIMAX_DEFAULT_MODEL)?.name ?? MINIMAX_DEFAULT_MODEL;
+ }
+ const normalized = normalizeMiniMaxModelId(id);
+ return getMiniMaxModelDefinition(normalized)?.name ?? normalized;
+}
diff --git a/lib/services/cli/minimax.ts b/lib/services/cli/minimax.ts
new file mode 100644
index 00000000..80582fc6
--- /dev/null
+++ b/lib/services/cli/minimax.ts
@@ -0,0 +1,795 @@
+/**
+ * MiniMax CLI Service
+ * Minimal Claude Agent SDK integration configured for MiniMax models.
+ * MiniMax exposes an Anthropic-compatible runtime in both the global
+ * (api.minimax.io) and China (api.minimaxi.com) regions.
+ */
+
+import { query } from '@anthropic-ai/claude-agent-sdk';
+import path from 'node:path';
+import fs from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import type { Message } from '@/types/backend';
+import type { RealtimeMessage } from '@/types';
+import { streamManager } from '@/lib/services/stream';
+import { createMessage } from '@/lib/services/message';
+import { getProjectById } from '@/lib/services/project';
+import { serializeMessage, createRealtimeMessage } from '@/lib/serializers/chat';
+import { loadGlobalSettings } from '@/lib/services/settings';
+import {
+ markUserRequestAsRunning,
+ markUserRequestAsCompleted,
+ markUserRequestAsFailed,
+} from '@/lib/services/user-requests';
+import {
+ MINIMAX_DEFAULT_MODEL,
+ getMiniMaxModelDisplayName,
+ normalizeMiniMaxModelId,
+} from '@/lib/constants/minimaxModels';
+
+/**
+ * Regional Anthropic-compatible endpoints for MiniMax.
+ * `global_en` targets the international API at api.minimax.io.
+ * `cn_zh` targets the China API at api.minimaxi.com.
+ */
+const MINIMAX_REGIONAL_BASE_URLS: Record = {
+ global_en: 'https://api.minimax.io/anthropic',
+ cn_zh: 'https://api.minimaxi.com/anthropic',
+};
+
+const MINIMAX_REGION =
+ process.env.MINIMAX_REGION?.trim() || 'global_en';
+
+const MINIMAX_ANTHROPIC_BASE_URL =
+ process.env.MINIMAX_ANTHROPIC_BASE_URL?.trim() ||
+ MINIMAX_REGIONAL_BASE_URLS[MINIMAX_REGION] ||
+ MINIMAX_REGIONAL_BASE_URLS.global_en;
+
+const MINIMAX_API_TIMEOUT_MS = process.env.MINIMAX_API_TIMEOUT_MS?.trim() || '3000000';
+
+const STATUS_LABELS: Record = {
+ starting: 'Initializing MiniMax agent...',
+ ready: 'MiniMax runtime ready',
+ running: 'MiniMax is processing your request...',
+ completed: 'MiniMax execution completed',
+};
+
+const AUTO_INSTRUCTIONS = `Act autonomously without waiting for confirmations.
+You are the MiniMax CLI assistant. Refer to yourself as MiniMax, not Claude.
+Work directly inside the current workspace (Next.js App Router with TypeScript and Tailwind CSS).
+Use Claude Code compatible tools to read, modify, and create files. Prefer apply_patch style edits when changing existing files.
+Do not create new top-level directories unless explicitly requested.
+Avoid running package managers or starting development servers; the platform handles previews.
+Explain your intent briefly when helpful, then take concrete actions until the task is complete.`;
+
+type StreamAccumulator = {
+ id: string;
+ content: string;
+ createdAt: string;
+ isStreaming: boolean;
+};
+
+async function ensureProjectPath(projectId: string, projectPath: string): Promise {
+ const project = await getProjectById(projectId);
+ if (!project) {
+ throw new Error(`Project not found: ${projectId}`);
+ }
+
+ const absolute = path.isAbsolute(projectPath)
+ ? path.resolve(projectPath)
+ : path.resolve(process.cwd(), projectPath);
+ const allowedBasePath = path.resolve(process.cwd(), process.env.PROJECTS_DIR || './data/projects');
+ const relativeToBase = path.relative(allowedBasePath, absolute);
+ const isWithinBase = !relativeToBase.startsWith('..') && !path.isAbsolute(relativeToBase);
+ if (!isWithinBase) {
+ throw new Error(`Project path must be within ${allowedBasePath}. Got: ${absolute}`);
+ }
+
+ try {
+ await fs.access(absolute);
+ } catch {
+ await fs.mkdir(absolute, { recursive: true });
+ }
+
+ return absolute;
+}
+
+async function appendProjectContext(baseInstruction: string, repoPath: string): Promise {
+ try {
+ const entries = await fs.readdir(repoPath, { withFileTypes: true });
+ const visible = entries
+ .filter((entry) => !entry.name.startsWith('.git') && entry.name !== 'AGENTS.md')
+ .map((entry) => entry.name);
+
+ if (visible.length === 0) {
+ return `${baseInstruction}
+
+
+This is an empty project directory. Work directly in the current folder without creating extra subdirectories.
+ `;
+ }
+
+ return `${baseInstruction}
+
+
+Current files in project directory: ${visible.sort().join(', ')}
+Work directly in the current directory. Do not create subdirectories unless specifically requested.
+ `;
+ } catch (error) {
+ console.warn('[MiniMaxService] Failed to append project context:', error);
+ return baseInstruction;
+ }
+}
+
+function publishStatus(projectId: string, status: string, requestId?: string, message?: string) {
+ streamManager.publish(projectId, {
+ type: 'status',
+ data: {
+ status,
+ message: message ?? STATUS_LABELS[status] ?? '',
+ ...(requestId ? { requestId } : {}),
+ },
+ });
+}
+
+async function persistAssistantMessage(
+ projectId: string,
+ payload: {
+ role: Message['role'];
+ messageType: Message['messageType'];
+ content: string;
+ metadata?: Record | null;
+ },
+ requestId?: string,
+ overrides?: Partial,
+) {
+ let lastError: Error | null = null;
+
+ // Retry logic with exponential backoff
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ try {
+ const saved = await createMessage({
+ projectId,
+ role: payload.role,
+ messageType: payload.messageType,
+ content: payload.content,
+ metadata: payload.metadata ?? null,
+ cliSource: 'minimax',
+ requestId,
+ });
+
+ streamManager.publish(projectId, {
+ type: 'message',
+ data: serializeMessage(saved, {
+ ...(requestId ? { requestId } : {}),
+ ...(overrides ?? {}),
+ }),
+ });
+
+ console.log(`[MiniMaxService] Successfully persisted message on attempt ${attempt}`);
+ return; // Success, exit the function
+ } catch (error) {
+ lastError = error as Error;
+ console.error(`[MiniMaxService] Attempt ${attempt} failed to persist assistant message:`, error);
+
+ if (attempt < 3) {
+ // Exponential backoff: 1s, 2s
+ const delayMs = Math.pow(2, attempt - 1) * 1000;
+ console.log(`[MiniMaxService] Retrying in ${delayMs}ms...`);
+ await new Promise(resolve => setTimeout(resolve, delayMs));
+ }
+ }
+ }
+
+ // All retries failed, fallback to realtime emit
+ console.error('[MiniMaxService] All retry attempts failed. Falling back to realtime emit:', lastError);
+ const fallback = createRealtimeMessage({
+ projectId,
+ role: payload.role,
+ messageType: payload.messageType,
+ content: payload.content,
+ metadata: payload.metadata ?? null,
+ cliSource: 'minimax',
+ requestId,
+ ...(overrides ?? {}),
+ });
+ streamManager.publish(projectId, {
+ type: 'message',
+ data: fallback,
+ });
+}
+
+async function persistToolMessage(
+ projectId: string,
+ content: string,
+ metadata: Record,
+ requestId?: string,
+ options: { persist?: boolean; isStreaming?: boolean; messageType?: 'tool_use' | 'tool_result' } = {},
+) {
+ const trimmed = content.trim();
+ if (!trimmed) return;
+
+ const { persist = true, isStreaming = false, messageType = 'tool_use' } = options;
+ const enrichedMetadata: Record = {
+ cli_type: 'minimax',
+ ...metadata,
+ };
+
+ if (!persist) {
+ const realtime = createRealtimeMessage({
+ projectId,
+ role: 'tool',
+ messageType,
+ content: trimmed,
+ metadata: enrichedMetadata,
+ cliSource: 'minimax',
+ requestId,
+ isStreaming,
+ isFinal: !isStreaming,
+ });
+ streamManager.publish(projectId, { type: 'message', data: realtime });
+ return;
+ }
+
+ await persistAssistantMessage(
+ projectId,
+ {
+ role: 'tool',
+ messageType,
+ content: trimmed,
+ metadata: enrichedMetadata,
+ },
+ requestId,
+ { isStreaming, isFinal: !isStreaming },
+ );
+}
+
+function createStreamAccumulator(requestId?: string): StreamAccumulator {
+ return {
+ id: requestId ? `minimax-stream-${requestId}` : `minimax-stream-${randomUUID()}`,
+ content: '',
+ createdAt: new Date().toISOString(),
+ isStreaming: false,
+ };
+}
+
+function emitStreamingUpdate(projectId: string, accumulator: StreamAccumulator, requestId?: string, isFinal: boolean = false) {
+ const realtime = createRealtimeMessage({
+ id: accumulator.id,
+ projectId,
+ role: 'assistant',
+ messageType: 'chat',
+ content: accumulator.content,
+ metadata: { cli_type: 'minimax' },
+ cliSource: 'minimax',
+ requestId,
+ createdAt: accumulator.createdAt,
+ isStreaming: !isFinal,
+ isFinal,
+ isOptimistic: true,
+ });
+ streamManager.publish(projectId, { type: 'message', data: realtime });
+ accumulator.isStreaming = !isFinal;
+}
+
+function extractTextDelta(delta: unknown): string {
+ if (typeof delta === 'string') {
+ return delta;
+ }
+ if (!delta || typeof delta !== 'object') {
+ return '';
+ }
+ const record = delta as Record;
+ if (typeof record.text === 'string') {
+ return record.text;
+ }
+ if (typeof record.delta === 'string') {
+ return record.delta;
+ }
+ if (typeof record.partial === 'string') {
+ return record.partial;
+ }
+ return '';
+}
+
+async function executeMiniMax(
+ projectId: string,
+ projectPath: string,
+ instruction: string,
+ model: string,
+ sessionId?: string,
+ requestId?: string,
+): Promise {
+ const normalizedModel = normalizeMiniMaxModelId(model);
+ const modelDisplayName = getMiniMaxModelDisplayName(normalizedModel);
+
+ let configuredApiKey: string | undefined;
+ try {
+ const globalSettings = await loadGlobalSettings();
+ const minimaxSettings = globalSettings.cli_settings?.minimax;
+ if (minimaxSettings && typeof minimaxSettings === 'object') {
+ const candidate = (minimaxSettings as Record).apiKey;
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
+ configuredApiKey = candidate.trim();
+ }
+ }
+ } catch (error) {
+ console.warn('[MiniMaxService] Failed to load MiniMax settings:', error);
+ }
+
+ const applyApiKey = (apiKey?: string) => {
+ const envUpdates: Record = {};
+
+ if (apiKey) {
+ const apiKeyTargets = [
+ 'MINIMAX_API_KEY',
+ 'MINIMAXI_API_KEY',
+ 'ANTHROPIC_AUTH_TOKEN',
+ 'ANTHROPIC_API_KEY',
+ ];
+ for (const key of apiKeyTargets) {
+ envUpdates[key] = apiKey;
+ }
+ }
+
+ if (!process.env.ANTHROPIC_BASE_URL) {
+ envUpdates.ANTHROPIC_BASE_URL = MINIMAX_ANTHROPIC_BASE_URL;
+ }
+
+ if (!process.env.API_TIMEOUT_MS) {
+ envUpdates.API_TIMEOUT_MS = MINIMAX_API_TIMEOUT_MS;
+ }
+
+ if (!process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) {
+ envUpdates.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
+ }
+
+ const previousValues: Record = {};
+ for (const [key, value] of Object.entries(envUpdates)) {
+ previousValues[key] = process.env[key];
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+
+ return () => {
+ for (const [key, previous] of Object.entries(previousValues)) {
+ if (previous === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = previous;
+ }
+ }
+ };
+ };
+
+ publishStatus(projectId, 'starting', requestId);
+ if (requestId) {
+ await markUserRequestAsRunning(requestId);
+ }
+
+ const absoluteProjectPath = await ensureProjectPath(projectId, projectPath);
+ const repoPath = await (async () => {
+ const candidate = path.join(absoluteProjectPath, 'repo');
+ try {
+ const stats = await fs.stat(candidate);
+ if (stats.isDirectory()) {
+ return candidate;
+ }
+ } catch {
+ // ignore
+ }
+ return absoluteProjectPath;
+ })();
+
+ publishStatus(projectId, 'ready', requestId, `MiniMax detected (${modelDisplayName}). Starting execution...`);
+
+ const promptBase = `${AUTO_INSTRUCTIONS}\n\n${instruction}`.trim();
+ const promptWithContext = await appendProjectContext(promptBase, repoPath);
+
+ const accumulator = createStreamAccumulator(requestId);
+ const stderrBuffer: string[] = [];
+ const toolNameById = new Map();
+ const emittedToolMessages = new Set();
+
+ const emitToolMessage = async (
+ content: string,
+ metadata: Record,
+ options: { persist?: boolean; isStreaming?: boolean; messageType?: 'tool_use' | 'tool_result' } = {},
+ ) => {
+ const baseMetadata = {
+ ...metadata,
+ };
+
+ const toolIdentifier =
+ (typeof baseMetadata.toolUseId === 'string' && baseMetadata.toolUseId) ||
+ (typeof baseMetadata.tool_name === 'string' && baseMetadata.tool_name) ||
+ (typeof baseMetadata.toolName === 'string' && baseMetadata.toolName) ||
+ '';
+ const messageType = options.messageType ?? 'tool_use';
+ const trimmedContent = content.trim();
+ const dedupeKey = `${messageType}|${toolIdentifier}|${trimmedContent}`;
+
+ if (dedupeKey.trim().length > 0) {
+ if (emittedToolMessages.has(dedupeKey)) {
+ return;
+ }
+ emittedToolMessages.add(dedupeKey);
+ }
+
+ await persistToolMessage(projectId, content, baseMetadata, requestId, options);
+ };
+
+ const maxOutputTokens = Number(process.env.MINIMAX_MAX_OUTPUT_TOKENS ?? '3200');
+
+ const restoreApiKey = applyApiKey(configuredApiKey);
+
+ try {
+ publishStatus(projectId, 'running', requestId);
+
+ const response = query({
+ prompt: promptWithContext,
+ options: {
+ workingDirectory: repoPath,
+ additionalDirectories: [repoPath],
+ model: normalizedModel,
+ resume: sessionId,
+ maxOutputTokens: Number.isFinite(maxOutputTokens) ? maxOutputTokens : 3200,
+ settingSources: ['user'],
+ permissionMode: 'bypassPermissions',
+ stderr: (data: string) => {
+ const line = String(data).trimEnd();
+ if (line) {
+ if (stderrBuffer.length > 200) stderrBuffer.shift();
+ stderrBuffer.push(line);
+ console.error(`[MiniMaxService][stderr] ${line}`);
+ }
+ },
+ } as any,
+ });
+
+ for await (const message of response) {
+ if (message.type === 'stream_event') {
+ const event: Record = (message as any).event ?? {};
+ const eventType = typeof event.type === 'string' ? event.type : '';
+
+ switch (eventType) {
+ case 'message_start': {
+ accumulator.content = '';
+ accumulator.isStreaming = false;
+ break;
+ }
+ case 'content_block_start': {
+ const block = event.content_block as Record | undefined;
+ if (block && block.type === 'tool_use') {
+ const toolName = typeof block.name === 'string' ? block.name : 'tool';
+ const toolUseIdValue = block.id ?? block.tool_use_id ?? block.toolUseId;
+ const toolUseId = typeof toolUseIdValue === 'string' ? toolUseIdValue : undefined;
+ if (toolUseId) {
+ toolNameById.set(toolUseId, toolName);
+ }
+ const metadata: Record = {
+ toolName,
+ tool_name: toolName,
+ ...(toolUseId ? { toolUseId } : {}),
+ };
+ if (block.input !== undefined) {
+ metadata.toolInput = block.input;
+ }
+ await emitToolMessage(
+ `Using tool: ${toolName}`,
+ metadata,
+ { persist: false, isStreaming: true, messageType: 'tool_use' },
+ );
+ }
+ break;
+ }
+ case 'content_block_delta': {
+ const textChunk = extractTextDelta(event.delta);
+ if (textChunk) {
+ accumulator.content += textChunk;
+ emitStreamingUpdate(projectId, accumulator, requestId, false);
+ }
+ break;
+ }
+ case 'content_block_stop': {
+ const block = event.content_block as Record | undefined;
+ if (block && block.type === 'tool_use') {
+ const toolName = typeof block.name === 'string' ? block.name : 'tool';
+ const toolUseIdValue = block.id ?? block.tool_use_id ?? block.toolUseId;
+ const toolUseId = typeof toolUseIdValue === 'string' ? toolUseIdValue : undefined;
+ if (toolUseId) {
+ toolNameById.set(toolUseId, toolName);
+ }
+ const metadata: Record = {
+ toolName,
+ tool_name: toolName,
+ ...(toolUseId ? { toolUseId } : {}),
+ };
+ await emitToolMessage(
+ `Finished using tool: ${toolName}`,
+ metadata,
+ { persist: true, isStreaming: false, messageType: 'tool_result' },
+ );
+ }
+ break;
+ }
+ case 'message_stop': {
+ if (accumulator.content.trim().length > 0) {
+ emitStreamingUpdate(projectId, accumulator, requestId, true);
+ await persistAssistantMessage(
+ projectId,
+ {
+ role: 'assistant',
+ messageType: 'chat',
+ content: accumulator.content.trim(),
+ metadata: { cli_type: 'minimax' },
+ },
+ requestId,
+ { isStreaming: false, isFinal: true, isOptimistic: false },
+ );
+ accumulator.content = '';
+ }
+ break;
+ }
+ case 'tool_result': {
+ const payload = event.output ?? event.result ?? event;
+ const rawToolName = event.tool_name ?? event.toolName;
+ let resultText: string;
+ if (typeof payload === 'string') {
+ resultText = payload;
+ } else if (Array.isArray(payload)) {
+ resultText = payload
+ .filter((entry): entry is string => typeof entry === 'string')
+ .join('\n');
+ } else if (payload && typeof payload === 'object' && typeof (payload as Record).text === 'string') {
+ resultText = (payload as Record).text as string;
+ } else {
+ try {
+ resultText = JSON.stringify(payload ?? {});
+ } catch {
+ resultText = String(payload ?? '');
+ }
+ }
+ const toolName = typeof rawToolName === 'string' ? rawToolName : 'tool';
+ const toolUseIdValue = event.tool_use_id ?? event.toolUseId ?? event.id;
+ const toolUseId = typeof toolUseIdValue === 'string' ? toolUseIdValue : undefined;
+ if (toolUseId && !toolNameById.has(toolUseId)) {
+ toolNameById.set(toolUseId, toolName);
+ }
+ const metadata: Record = {
+ toolName,
+ tool_name: toolName,
+ ...(toolUseId ? { toolUseId } : {}),
+ };
+ await emitToolMessage(
+ resultText,
+ metadata,
+ { persist: true, isStreaming: false, messageType: 'tool_result' },
+ );
+ break;
+ }
+ default:
+ // noop for other event types
+ break;
+ }
+ } else if (message.type === 'assistant') {
+ const assistantRecord = (message as any).message as Record | undefined;
+ const contentBlocks = Array.isArray(assistantRecord?.content) ? (assistantRecord!.content as unknown[]) : [];
+ let appendedText = false;
+
+ for (const block of contentBlocks) {
+ if (!block || typeof block !== 'object') continue;
+ const blockRecord = block as Record;
+ const blockType = typeof blockRecord.type === 'string' ? blockRecord.type : '';
+
+ if (blockType === 'text') {
+ const text = typeof blockRecord.text === 'string' ? blockRecord.text : '';
+ if (text) {
+ accumulator.content += text;
+ appendedText = true;
+ }
+ } else if (blockType === 'tool_use') {
+ const toolName = typeof blockRecord.name === 'string' ? blockRecord.name : 'tool';
+ const toolUseIdValue = blockRecord.id ?? blockRecord.tool_use_id ?? blockRecord.toolUseId;
+ const toolUseId = typeof toolUseIdValue === 'string' ? toolUseIdValue : undefined;
+ if (toolUseId) {
+ toolNameById.set(toolUseId, toolName);
+ }
+ const metadata: Record = {
+ toolName,
+ tool_name: toolName,
+ ...(toolUseId ? { toolUseId } : {}),
+ };
+ if (blockRecord.input !== undefined) {
+ metadata.toolInput = blockRecord.input;
+ }
+ await emitToolMessage(
+ `Using tool: ${toolName}`,
+ metadata,
+ { persist: false, isStreaming: true, messageType: 'tool_use' },
+ );
+ }
+ }
+
+ if (appendedText) {
+ emitStreamingUpdate(projectId, accumulator, requestId, false);
+ }
+ } else if (message.type === 'user') {
+ const userRecord = (message as any).message as Record | undefined;
+ const contentBlocks = Array.isArray(userRecord?.content) ? (userRecord!.content as unknown[]) : [];
+
+ for (const block of contentBlocks) {
+ if (!block || typeof block !== 'object') continue;
+ const blockRecord = block as Record;
+ const blockType = typeof blockRecord.type === 'string' ? blockRecord.type : '';
+
+ if (blockType === 'tool_result') {
+ const toolUseIdValue = blockRecord.tool_use_id ?? blockRecord.toolUseId ?? blockRecord.id;
+ const toolUseId = typeof toolUseIdValue === 'string' ? toolUseIdValue : undefined;
+ const toolName = toolUseId ? toolNameById.get(toolUseId) : undefined;
+ const metadata: Record = {
+ ...(toolName ? { toolName, tool_name: toolName } : {}),
+ ...(toolUseId ? { toolUseId } : {}),
+ };
+
+ const rawContent = blockRecord.content ?? blockRecord.result ?? blockRecord.output;
+ let resultText: string;
+ if (typeof rawContent === 'string') {
+ resultText = rawContent;
+ } else if (Array.isArray(rawContent)) {
+ resultText = rawContent
+ .map((entry) => (typeof entry === 'string' ? entry : ''))
+ .filter((entry) => entry.length > 0)
+ .join('\n');
+ } else if (rawContent && typeof rawContent === 'object') {
+ try {
+ resultText = JSON.stringify(rawContent, null, 2);
+ } catch {
+ resultText = String(rawContent);
+ }
+ } else {
+ resultText = '';
+ }
+
+ await emitToolMessage(
+ resultText,
+ metadata,
+ { persist: true, isStreaming: false, messageType: 'tool_result' },
+ );
+ }
+ }
+ } else if (message.type === 'result') {
+ const resultRecord = message as Record;
+ const output = resultRecord.output ?? resultRecord.content;
+ if (!accumulator.content.trim() && typeof output === 'string' && output.trim().length > 0) {
+ accumulator.content = output.trim();
+ emitStreamingUpdate(projectId, accumulator, requestId, true);
+ await persistAssistantMessage(
+ projectId,
+ {
+ role: 'assistant',
+ messageType: 'chat',
+ content: accumulator.content,
+ metadata: { cli_type: 'minimax' },
+ },
+ requestId,
+ { isStreaming: false, isFinal: true, isOptimistic: false },
+ );
+ }
+ publishStatus(projectId, 'completed', requestId);
+ if (requestId) {
+ await markUserRequestAsCompleted(requestId);
+ }
+ }
+ }
+
+ // If stream finished without emitting final message
+ if (accumulator.content.trim().length > 0) {
+ emitStreamingUpdate(projectId, accumulator, requestId, true);
+ await persistAssistantMessage(
+ projectId,
+ {
+ role: 'assistant',
+ messageType: 'chat',
+ content: accumulator.content.trim(),
+ metadata: { cli_type: 'minimax' },
+ },
+ requestId,
+ { isStreaming: false, isFinal: true, isOptimistic: false },
+ );
+ accumulator.content = '';
+ }
+
+ publishStatus(projectId, 'completed', requestId);
+ if (requestId) {
+ await markUserRequestAsCompleted(requestId);
+ }
+ } catch (error) {
+ const stderrTail = stderrBuffer.slice(-15).join('\n');
+ let errorMessage =
+ error instanceof Error
+ ? error.message
+ : stderrTail || 'MiniMax execution failed';
+
+ const hasTail = Boolean(stderrTail);
+
+ if (/process exited with code\s+\d+/i.test(errorMessage)) {
+ const exitCodeMatch = errorMessage.match(/process exited with code\s+(\d+)/i);
+ const exitCode = exitCodeMatch?.[1] ?? '1';
+ errorMessage = [
+ `Claude Code runtime exited with code ${exitCode}.`,
+ 'Verify the Claude Code runtime is installed and authenticated for MiniMax:',
+ '1. Confirm the binary: `claude --version` and run `claude update` if prompted.',
+ `2. Ensure a valid MiniMax API key is configured (Settings → AI Agents → MiniMax CLI or set \`MINIMAX_API_KEY\`).`,
+ `3. Confirm the Anthropic-compatible endpoint (${MINIMAX_ANTHROPIC_BASE_URL}) is reachable for the ${MINIMAX_REGION} region.`,
+ ].join('\n');
+ } else if (/ENOENT|command not found|no such file or directory/i.test(errorMessage)) {
+ errorMessage = [
+ 'Unable to launch Claude Code runtime for MiniMax.',
+ 'Ensure the runtime is installed and available on your PATH:',
+ '- Verify: `claude --version`',
+ '- Restart Claudable after installation',
+ ].join('\n');
+ }
+
+ if (hasTail && !errorMessage.includes('Detailed log:')) {
+ errorMessage = `${errorMessage}\n\nDetailed log:\n${stderrTail}`;
+ }
+
+ publishStatus(projectId, 'completed', requestId, 'MiniMax execution ended with errors');
+ if (requestId) {
+ await markUserRequestAsFailed(requestId, errorMessage);
+ }
+
+ await persistAssistantMessage(
+ projectId,
+ {
+ role: 'assistant',
+ messageType: 'chat',
+ content: `⚠️ MiniMax reported an error:\n${errorMessage}`,
+ metadata: { cli_type: 'minimax', error: true },
+ },
+ requestId,
+ { isStreaming: false, isFinal: true, isOptimistic: false },
+ );
+
+ throw error instanceof Error ? error : new Error(errorMessage);
+ } finally {
+ try {
+ restoreApiKey();
+ } catch (cleanupError) {
+ console.warn('[MiniMaxService] Failed to restore MiniMax API key env:', cleanupError);
+ }
+ }
+}
+
+export async function initializeNextJsProject(
+ projectId: string,
+ projectPath: string,
+ initialPrompt: string,
+ model: string = MINIMAX_DEFAULT_MODEL,
+ requestId?: string,
+): Promise {
+ const fullPrompt = `
+Create a new Next.js 15 application with the following requirements:
+${initialPrompt}
+
+Use App Router, TypeScript, and Tailwind CSS.
+Set up the basic project structure and implement the requested features.`.trim();
+
+ await executeMiniMax(projectId, projectPath, fullPrompt, model, undefined, requestId);
+}
+
+export async function applyChanges(
+ projectId: string,
+ projectPath: string,
+ instruction: string,
+ model: string = MINIMAX_DEFAULT_MODEL,
+ sessionId?: string,
+ requestId?: string,
+): Promise {
+ await executeMiniMax(projectId, projectPath, instruction, model, sessionId, requestId);
+}
diff --git a/lib/services/settings.ts b/lib/services/settings.ts
index d8111149..94b2e8e4 100644
--- a/lib/services/settings.ts
+++ b/lib/services/settings.ts
@@ -30,6 +30,9 @@ const DEFAULT_SETTINGS: GlobalSettings = {
glm: {
model: getDefaultModelForCli('glm'),
},
+ minimax: {
+ model: getDefaultModelForCli('minimax'),
+ },
},
};
diff --git a/lib/utils/cliOptions.ts b/lib/utils/cliOptions.ts
index 1cd8fc91..1647da7d 100644
--- a/lib/utils/cliOptions.ts
+++ b/lib/utils/cliOptions.ts
@@ -1,7 +1,7 @@
import { CLI_OPTIONS, type CLIOption } from '@/types/cli';
import { getModelDefinitionsForCli, normalizeModelId } from '@/lib/constants/cliModels';
-export const ACTIVE_CLI_IDS = ['claude', 'codex', 'cursor', 'qwen', 'glm'] as const;
+export const ACTIVE_CLI_IDS = ['claude', 'codex', 'cursor', 'qwen', 'glm', 'minimax'] as const;
export type ActiveCliId = (typeof ACTIVE_CLI_IDS)[number];
diff --git a/public/minimax.svg b/public/minimax.svg
new file mode 100644
index 00000000..94bd7d0d
--- /dev/null
+++ b/public/minimax.svg
@@ -0,0 +1,11 @@
+
+ MiniMax
+
+
+
+
+
+
+
+
+
diff --git a/types/backend/cli.ts b/types/backend/cli.ts
index e41bf9be..299c5b9e 100644
--- a/types/backend/cli.ts
+++ b/types/backend/cli.ts
@@ -2,7 +2,7 @@
* AI CLI-related types
*/
-export type CLIType = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'glm';
+export type CLIType = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'glm' | 'minimax';
export type SessionType = 'chat' | 'code_gen' | 'error_fix';
diff --git a/types/cli.ts b/types/cli.ts
index f1cdfc6a..6e08c4d9 100644
--- a/types/cli.ts
+++ b/types/cli.ts
@@ -3,12 +3,13 @@ import { CODEX_MODEL_DEFINITIONS } from '@/lib/constants/codexModels';
import { CURSOR_MODEL_DEFINITIONS } from '@/lib/constants/cursorModels';
import { QWEN_MODEL_DEFINITIONS } from '@/lib/constants/qwenModels';
import { GLM_MODEL_DEFINITIONS } from '@/lib/constants/glmModels';
+import { MINIMAX_MODEL_DEFINITIONS } from '@/lib/constants/minimaxModels';
/**
* Frontend CLI Type Definitions (claude-only variant)
*/
-export type CLIType = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'glm';
+export type CLIType = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'glm' | 'minimax';
export interface CLIModel {
id: string;
@@ -152,4 +153,24 @@ export const CLI_OPTIONS: CLIOption[] = [
supportsImages,
})),
},
+ {
+ id: 'minimax',
+ name: 'MiniMax CLI',
+ description: 'MiniMax agent running through Claude Code runtime',
+ icon: '/minimax.svg',
+ available: true,
+ configured: true,
+ enabled: true,
+ color: 'from-purple-500 to-fuchsia-600',
+ brandColor: '#FF2E5F',
+ downloadUrl: 'https://platform.minimax.io/docs',
+ installCommand: 'npm install -g @anthropic-ai/claude-code',
+ features: ['Claude-compatible agent runtime', 'MiniMax M3 reasoning', 'Multimodal input'],
+ models: MINIMAX_MODEL_DEFINITIONS.map(({ id, name, description, supportsImages }) => ({
+ id,
+ name,
+ description,
+ supportsImages,
+ })),
+ },
];