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 { 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(); const duplicates = new Set(); 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(); 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 { 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 { 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 = `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}/*')`; 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"); }, });