mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import { tool } from "@opencode-ai/plugin";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import * as fs from "node:fs";
|
|
|
|
type WindowLaunchPlan = {
|
|
model: string;
|
|
windowName: string;
|
|
};
|
|
|
|
type CommandResult = {
|
|
ok: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
const MAX_SUGGESTIONS = 3;
|
|
const WINDOW_NAME_LIMIT = 24;
|
|
|
|
/**
|
|
* Runs a command and captures its output without throwing on non-zero exit codes.
|
|
*
|
|
* @param parts Command segments to pass to the shell.
|
|
* @returns The exit status plus captured stdout and stderr.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const result = await runCommand(["command", "-v", "tmux"]);
|
|
* if (!result.ok) {
|
|
* return "Error: `tmux` is not installed.";
|
|
* }
|
|
* ```
|
|
*/
|
|
async function runCommand(parts: string[]): Promise<CommandResult> {
|
|
const result = await Bun.$`${parts}`.quiet().nothrow();
|
|
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
stdout: result.stdout.toString().trim(),
|
|
stderr: result.stderr.toString().trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Escapes a value for safe use inside a shell command string.
|
|
*
|
|
* @param value Raw user-provided value.
|
|
* @returns A POSIX-safe single-quoted string.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`;
|
|
* ```
|
|
*/
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
/**
|
|
* Normalizes requested model ids by trimming whitespace and dropping empty items.
|
|
*
|
|
* @param models Raw tool input.
|
|
* @returns Clean model ids in the original order.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const normalized = normalizeModels([" openai/gpt-5.4 ", ""]);
|
|
* // ["openai/gpt-5.4"]
|
|
* ```
|
|
*/
|
|
function normalizeModels(models: string[] | undefined): string[] {
|
|
return (models ?? []).map((model) => model.trim()).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Finds duplicate values while preserving their first repeated occurrence order.
|
|
*
|
|
* @param values Values to inspect.
|
|
* @returns Duplicate entries exactly once each.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const duplicates = findDuplicates(["a", "b", "a", "b"]);
|
|
* // ["a", "b"]
|
|
* ```
|
|
*/
|
|
function findDuplicates(values: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const duplicates = new Set<string>();
|
|
|
|
for (const value of values) {
|
|
if (seen.has(value)) {
|
|
duplicates.add(value);
|
|
continue;
|
|
}
|
|
|
|
seen.add(value);
|
|
}
|
|
|
|
return [...duplicates];
|
|
}
|
|
|
|
/**
|
|
* Builds a short tmux-safe window label from a model id.
|
|
*
|
|
* @param model Full model id.
|
|
* @returns A concise window label.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const label = createWindowBaseName("openai/gpt-5.4");
|
|
* // "gpt-5-4"
|
|
* ```
|
|
*/
|
|
function createWindowBaseName(model: string): string {
|
|
const preferredPart = model.split("/").at(-1) ?? model;
|
|
const sanitized = preferredPart
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, WINDOW_NAME_LIMIT);
|
|
|
|
return sanitized || "model";
|
|
}
|
|
|
|
/**
|
|
* Makes window names unique when sanitized model labels collide.
|
|
*
|
|
* @param models Validated model ids.
|
|
* @returns Window plans containing the full model id and unique tmux window name.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const plans = createWindowPlans(["provider/a", "other/a"]);
|
|
* // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }]
|
|
* ```
|
|
*/
|
|
function createWindowPlans(models: string[]): WindowLaunchPlan[] {
|
|
const counts = new Map<string, number>();
|
|
|
|
return models.map((model) => {
|
|
const baseName = createWindowBaseName(model);
|
|
const nextCount = (counts.get(baseName) ?? 0) + 1;
|
|
counts.set(baseName, nextCount);
|
|
|
|
if (nextCount === 1) {
|
|
return { model, windowName: baseName };
|
|
}
|
|
|
|
const suffix = `-${nextCount}`;
|
|
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
|
|
|
|
return {
|
|
model,
|
|
windowName: `${trimmedBase}${suffix}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Computes Levenshtein distance for fuzzy model suggestions.
|
|
*
|
|
* @param left First string.
|
|
* @param right Second string.
|
|
* @returns Edit distance between the two strings.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const distance = levenshtein("gpt5.4", "gpt-5.4");
|
|
* // 1
|
|
* ```
|
|
*/
|
|
function levenshtein(left: string, right: string): number {
|
|
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
|
|
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
let previous = row[0];
|
|
row[0] = leftIndex;
|
|
|
|
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
const current = row[rightIndex];
|
|
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
|
|
row[rightIndex] = Math.min(
|
|
row[rightIndex] + 1,
|
|
row[rightIndex - 1] + 1,
|
|
previous + cost,
|
|
);
|
|
previous = current;
|
|
}
|
|
}
|
|
|
|
return row[right.length];
|
|
}
|
|
|
|
/**
|
|
* Suggests close model ids for invalid input.
|
|
*
|
|
* @param requested Invalid requested model id.
|
|
* @param allowlist Known valid model ids.
|
|
* @returns Up to three likely matches ordered by relevance.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]);
|
|
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
|
|
* ```
|
|
*/
|
|
function suggestModels(requested: string, allowlist: string[]): string[] {
|
|
const normalizedRequested = requested.toLowerCase();
|
|
|
|
return allowlist
|
|
.map((candidate) => {
|
|
const normalizedCandidate = candidate.toLowerCase();
|
|
const distance = levenshtein(normalizedRequested, normalizedCandidate);
|
|
const containsBoost =
|
|
normalizedCandidate.includes(normalizedRequested) ||
|
|
normalizedRequested.includes(normalizedCandidate)
|
|
? -2
|
|
: 0;
|
|
|
|
return {
|
|
candidate,
|
|
score: distance + containsBoost,
|
|
};
|
|
})
|
|
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
|
|
.slice(0, MAX_SUGGESTIONS)
|
|
.map(({ candidate }) => candidate);
|
|
}
|
|
|
|
/**
|
|
* Formats invalid model errors with repair hints.
|
|
*
|
|
* @param invalidModels Invalid requested model ids.
|
|
* @param allowlist Known valid model ids.
|
|
* @returns A user-facing error string.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
|
|
* ```
|
|
*/
|
|
function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string {
|
|
const [firstInvalidModel] = invalidModels;
|
|
const suggestions = suggestModels(firstInvalidModel, allowlist);
|
|
const suggestionText =
|
|
suggestions.length > 0
|
|
? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?`
|
|
: " Run `opencode models` and try again.";
|
|
|
|
if (invalidModels.length === 1) {
|
|
return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`;
|
|
}
|
|
|
|
return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`;
|
|
}
|
|
|
|
/**
|
|
* Launches an OpenCode command in a tmux window.
|
|
*
|
|
* @param sessionName Existing tmux session name.
|
|
* @param plan Window launch plan.
|
|
* @returns Result describing whether the command was sent successfully.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" });
|
|
* ```
|
|
*/
|
|
async function launchModelInWindow(sessionName: string, plan: WindowLaunchPlan): Promise<CommandResult> {
|
|
const launchCommand = `opencode --model ${shellQuote(plan.model)}`;
|
|
|
|
// Send the exact command text into the pane so tmux keeps the user's normal shell setup.
|
|
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a session name to be safe for paths and branch names.
|
|
*/
|
|
function sanitizeName(name: string): string {
|
|
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Generates the full absolute path for a worktree.
|
|
*/
|
|
function getWorktreePath(safeSession: string, windowName: string): string {
|
|
return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName);
|
|
}
|
|
|
|
/**
|
|
* Removes a git worktree and deletes its branch on failure.
|
|
*/
|
|
async function undoWorktree(worktreePath: string, branchName: string): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]);
|
|
if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`);
|
|
|
|
const branchRes = await runCommand(["git", "branch", "-D", branchName]);
|
|
if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
|
|
return errors;
|
|
}
|
|
|
|
export default tool({
|
|
description: "Launch multiple OpenCode models in tmux",
|
|
args: {
|
|
sessionName: tool.schema.string().min(1).describe("tmux session name to create"),
|
|
models: tool.schema
|
|
.array(tool.schema.string().min(1))
|
|
.min(1)
|
|
.describe("one or more OpenCode model ids to launch"),
|
|
},
|
|
async execute(args, context) {
|
|
const sessionName = args.sessionName.trim();
|
|
const safeSessionName = sanitizeName(sessionName);
|
|
const models = normalizeModels(args.models);
|
|
|
|
if (!sessionName) {
|
|
return "Error: `sessionName` must not be empty.";
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
return "Error: Model names must not be empty.";
|
|
}
|
|
|
|
const duplicateModels = findDuplicates(models);
|
|
if (duplicateModels.length > 0) {
|
|
return `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 "Error: `multi-model` requires a git repository.";
|
|
}
|
|
|
|
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
|
|
if (!tmuxExists.ok) {
|
|
return "Error: `tmux` is not installed or not on `PATH`.";
|
|
}
|
|
|
|
const opencodeExists = await runCommand(["command", "-v", "opencode"]);
|
|
if (!opencodeExists.ok) {
|
|
return "Error: `opencode` is not installed or not on `PATH`.";
|
|
}
|
|
|
|
const modelListResult = await runCommand(["opencode", "models"]);
|
|
if (!modelListResult.ok) {
|
|
return `Error: Failed to load valid models from \`opencode 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 formatInvalidModelError(invalidModels, allowlist);
|
|
}
|
|
|
|
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
|
|
if (sessionExists.ok) {
|
|
return "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 `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 `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 `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 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);
|
|
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 attachCommand = `tmux attach -t ${sessionName}`;
|
|
const cleanupCommand = `rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
|
|
|
|
context.metadata({
|
|
title: `multi-model ${sessionName}`,
|
|
metadata: {
|
|
safeSessionName,
|
|
modelCount: models.length,
|
|
},
|
|
});
|
|
|
|
if (failedModels.length > 0) {
|
|
return [
|
|
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
|
|
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
|
|
`Failed: ${failedModels.join(", ")}.`,
|
|
`Attach with \`${attachCommand}\` to inspect the session.`,
|
|
`Cleanup with \n\`${cleanupCommand}\`\nwhen done.`
|
|
].join("\n");
|
|
}
|
|
|
|
return [
|
|
`Use \`${attachCommand}\` to join session.`,
|
|
`When finished, clean up worktrees with:`,
|
|
`\`${cleanupCommand}\``
|
|
].join("\n");
|
|
},
|
|
});
|