import * as fs from "node:fs"; import chalk from "chalk"; import type { MultiModelOptions, MultiModelResult } from "../types"; import { createWindowPlans, findDuplicates, formatInvalidModelError, getWorktreePath, launchModelInWindow, normalizeModels, runCommand, sanitizeName, undoWorktree, } from "./utils"; /** * Launches multiple AI models in a tmux session with git worktrees. * * Creates a tmux session with one window per model, each in its own git worktree. * Validates models against `opencode models` output before launching. * * @param options Launch configuration including session name and model list. * @returns Result with success status, window names, and usage instructions. * * @example * ```ts * const result = await openMultiModel({ * sessionName: "compare", * models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], * binaryName: "opencode", * }); * if (result.success) { * console.log(result.instructions); * } * ``` */ export async function openMultiModel( options: MultiModelOptions, ): Promise { const sessionName = options.sessionName.trim(); const safeSessionName = sanitizeName(sessionName); const models = normalizeModels(options.models); const binaryName = options.binaryName || "opencode"; const mode = options.mode || "cli"; if (!sessionName) { return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty.", }; } if (models.length === 0) { return { success: false, sessionName, error: "Error: Model names must not be empty.", }; } const duplicateModels = findDuplicates(models); if (duplicateModels.length > 0) { return { success: false, sessionName, error: `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`, }; } const gitRepoCheck = await runCommand([ "git", "rev-parse", "--is-inside-work-tree", ]); if (!gitRepoCheck.ok) { return { success: false, sessionName, error: "Error: `multi-model` requires a git repository.", }; } const tmuxExists = await runCommand(["command", "-v", "tmux"]); if (!tmuxExists.ok) { return { success: false, sessionName, error: "Error: `tmux` is not installed or not on `PATH`.", }; } const binaryExists = await runCommand(["command", "-v", binaryName]); if (!binaryExists.ok) { return { success: false, sessionName, error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.`, }; } const modelListResult = await runCommand([binaryName, "models"]); if (!modelListResult.ok) { return { success: false, sessionName, error: `Error: Failed to load valid models from \`${binaryName} models\`${modelListResult.stderr ? `: ${modelListResult.stderr}` : "."}`, }; } const allowlist = modelListResult.stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const invalidModels = models.filter((model) => !allowlist.includes(model)); if (invalidModels.length > 0) { return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist), }; } const sessionExists = await runCommand([ "tmux", "has-session", "-t", sessionName, ]); if (sessionExists.ok) { return { success: false, sessionName, error: "Error: Session already exists. Use a different `sessionName`.", }; } const windowPlans = createWindowPlans(models); const succeededModels: string[] = []; const failedModels: string[] = []; for (let i = 0; i < windowPlans.length; i++) { const plan = windowPlans[i]!; const isFirst = i === 0; const worktreePath = getWorktreePath(safeSessionName, plan.windowName); const branchName = `opencode/${safeSessionName}/${plan.windowName}`; if (fs.existsSync(worktreePath)) { if (!isFirst) { failedModels.push( `${plan.model} (Worktree path already exists at ${worktreePath})`, ); continue; } else { return { success: false, sessionName, error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`, }; } } const branchExists = await runCommand([ "git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`, ]); if (branchExists.ok) { if (!isFirst) { failedModels.push( `${plan.model} (Branch '${branchName}' already exists)`, ); continue; } else { return { success: false, sessionName, error: `Error: Branch '${branchName}' already exists.`, }; } } const worktreeResult = await runCommand([ "git", "worktree", "add", "-b", branchName, worktreePath, ]); if (!worktreeResult.ok) { if (!isFirst) { failedModels.push( `${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`, ); continue; } else { return { success: false, sessionName, error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`, }; } } if (isFirst) { const sessionCreateResult = await runCommand([ "tmux", "new-session", "-d", "-s", sessionName, "-n", plan.windowName, "-c", worktreePath, ]); if (!sessionCreateResult.ok) { const undoErrors = await undoWorktree(worktreePath, branchName); let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; if (undoErrors.length > 0) { errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`; } return { success: false, sessionName, error: errorMsg }; } } else { const windowCreateResult = await runCommand([ "tmux", "new-window", "-d", "-t", sessionName, "-n", plan.windowName, "-c", worktreePath, ]); if (!windowCreateResult.ok) { failedModels.push( `${plan.model} (${windowCreateResult.stderr || "failed to create window"})`, ); const undoErrors = await undoWorktree(worktreePath, branchName); if (undoErrors.length > 0) { failedModels.push( `cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`, ); } continue; } } const launchResult = await launchModelInWindow( sessionName, plan, binaryName, ); if (launchResult.ok) { succeededModels.push(plan.model); } else { failedModels.push( `${plan.model} (${launchResult.stderr || "failed to send launch command"})`, ); const undoErrors = await undoWorktree(worktreePath, branchName); if (undoErrors.length > 0) failedModels.push( `cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`, ); } } const windowNames = windowPlans.map((p) => p.windowName); const attachCommand = `tmux attach -t ${sessionName}`; const cleanupCommand = `tmux kill-session -t ${sessionName} && rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`; const primaryCleanup = mode === "tool" ? `call tool multi-model-close with sessionName: ${sessionName}` : `opencode-multi-model close ${sessionName}`; if (failedModels.length > 0) { return { success: true, sessionName, windows: windowNames, instructions: [ chalk.red( `Error: Created tmux session '${sessionName}', but some model launches failed.`, ), chalk.red(`Failed: ${failedModels.join(", ")}.`), chalk.dim( `Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`, ), "", chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`), "", chalk.dim(`When finished, clean up using:`), primaryCleanup, "", chalk.dim(`Alternate manual cleanup - `), chalk.dim(cleanupCommand), ].join("\n"), }; } return { success: true, sessionName, windows: windowNames, instructions: [ chalk.bold(`Use \`${attachCommand}\` to join session.`), "", chalk.dim(`When finished, clean up using:`), primaryCleanup, "", chalk.dim(`Alternate manual cleanup - `), chalk.dim(cleanupCommand), ].join("\n"), }; }