import { tool } from "@opencode-ai/plugin"; 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"]); } 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 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 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 [firstWindow, ...remainingWindows] = windowPlans; const succeededModels: string[] = []; const failedModels: string[] = []; // `tmux new-session` always creates the session's initial window, so we reuse that required first window for the first model. const sessionCreateResult = await runCommand([ "tmux", "new-session", "-d", "-s", sessionName, "-n", firstWindow.windowName, "-c", context.directory, ]); if (!sessionCreateResult.ok) { return `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; } const firstLaunchResult = await launchModelInWindow(sessionName, firstWindow); if (firstLaunchResult.ok) { succeededModels.push(firstWindow.model); } else { failedModels.push(`${firstWindow.model} (${firstLaunchResult.stderr || "failed to send launch command"})`); } for (const plan of remainingWindows) { const windowCreateResult = await runCommand([ "tmux", "new-window", "-d", "-t", sessionName, "-n", plan.windowName, "-c", context.directory, ]); if (!windowCreateResult.ok) { failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`); continue; } const launchResult = await launchModelInWindow(sessionName, plan); if (!launchResult.ok) { failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`); continue; } succeededModels.push(plan.model); } const attachCommand = `tmux attach -t ${sessionName}`; context.metadata({ title: `multi-model ${sessionName}`, metadata: { sessionName, 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.`, ].join(" "); } return `Use \`${attachCommand}\` to join session.`; }, });