mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
import * as fs from "node:fs";
|
|
import chalk from 'chalk';
|
|
import type { MultiModelOptions, MultiModelResult } from "../types";
|
|
import {
|
|
runCommand,
|
|
normalizeModels,
|
|
findDuplicates,
|
|
createWindowPlans,
|
|
formatInvalidModelError,
|
|
sanitizeName,
|
|
getWorktreePath,
|
|
getSessionPath,
|
|
undoWorktree,
|
|
launchModelInWindow,
|
|
} 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<MultiModelResult> {
|
|
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"),
|
|
};
|
|
}
|