From 5a4752f894bcdec5e692f3471d792138f848b581 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Thu, 12 Mar 2026 17:03:04 +0530 Subject: [PATCH] Basic impl with tmux --- .gitignore | 14 + .opencode/plans/archive/draft-multi-model.md | 119 ++++++ .opencode/tools/multi-model.ts | 407 +++++++++++++++++++ 3 files changed, 540 insertions(+) create mode 100644 .gitignore create mode 100644 .opencode/plans/archive/draft-multi-model.md create mode 100644 .opencode/tools/multi-model.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1601d23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +dist/ +build/ +coverage/ +.next/ +.turbo/ + +*.tsbuildinfo diff --git a/.opencode/plans/archive/draft-multi-model.md b/.opencode/plans/archive/draft-multi-model.md new file mode 100644 index 0000000..eb4f431 --- /dev/null +++ b/.opencode/plans/archive/draft-multi-model.md @@ -0,0 +1,119 @@ +--- +llm: openai/gpt-5.4 +status: done +--- + +# Multi-model launcher plan + +## Goal + +Create an OpenCode project-local custom tool that: + +1. accepts a tmux session name plus one or more model names +2. validates that the required tools and arguments are present +3. validates each requested model against `opencode models` +4. creates one tmux session and one window per model +5. launches `opencode --model ` in each window +6. prints a reliable attach instruction for the user + +The right OpenCode primitive for this is a project-local custom tool in `.opencode/tools/`, not a slash command. Custom tools support typed argument schemas and programmatic execution, which makes validation sturdier, faster, and less dependent on prompt following. + +## Recommended implementation shape + +- File: `.opencode/tools/multi-model.ts` +- Tool name: `multi-model` +- Arguments: + - `sessionName: string` + - `models?: string[]` + +## Proposed tool behavior + +The tool should do the following in order: + +1. Verify `sessionName` exists and at least one model was supplied. +2. Verify `tmux` is installed with `command -v tmux`. +3. Verify `opencode` is installed with `command -v opencode`. +4. Run `opencode models` and build an allowlist of valid model ids. +5. Reject any requested model that is not in the allowlist. +6. Reject duplicate model ids in the same invocation. +7. Check whether tmux session `sessionName` already exists. +8. If a requested model is invalid, compute close matches from the allowlist and return a error message with instruction to fix the tool call. +9. If no models are provided, return error. +10. Create tmux session detached. +11. Create one window per validated model. +12. In each window, launch `opencode --model `. +13. Return `tmux attach -t `. +14. If any launch step fails after session creation, return exactly what succeeded and what failed. + +## Reliability and edge cases + + +### Missing dependencies + +- `tmux` not installed: abort before any work. +- `opencode` not installed or not on `PATH`: abort before any work. + +Example: + +```ts +const tmuxExists = await Bun.$`command -v tmux`.quiet().nothrow() +``` + +This is non-obvious because the tool should detect the environment first instead of failing halfway through tmux setup. + + +## Suggested implementation notes + +- Use shell-safe quoting for all user-provided values. +- Long model names may be truncated by tmux window naming: use a short sanitized label for the window name, but keep the full model id in the command. + +Example: + +```text +Window name: gpt-5-4 +Launch command: opencode --model openai/gpt-5.4 +``` + + +## Example tool outline + +```ts +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Launch multiple OpenCode models in tmux", + args: { + sessionName: tool.schema.string().min(1), + models: tool.schema.array(tool.schema.string().min(1)).min(1), + }, + async execute(args, context) { + // 1. verify tmux and opencode exist + // 2. load valid models from `opencode models` + // 3. reject invalid or duplicate models + // 4. fail if session already exists + // 5. create session and windows + // 6. return structured success payload with attach command + return "Use `tmux attach -t sessionName` to join session."; + }, +}) +``` + +This example is intentionally minimal; the real implementation should return structured errors and partial-success details. + +## Return value + +The tool should return freeform text. + +For success - "Use `tmux attach -t sessionName` to join session." + +For failures: "Error: Session already exists. Use a different `sessionName`.", + +For invalid model name: "Error: Model name 'openai/gpt5.4' not found. Did you mean 'openai/gpt-5.4' or 'openai/gpt-5.4-mini'?", + +For missing models array: "Error: Model names must not be empty." + + +## Future ideas + +1. Add a project-local plugin wrapper for logging, richer UI integration, or event hooks while keeping the tool as the execution engine. +2. Package the tool or plugin as an npm-distributed OpenCode plugin for reuse across multiple repositories and machines. diff --git a/.opencode/tools/multi-model.ts b/.opencode/tools/multi-model.ts new file mode 100644 index 0000000..a083b34 --- /dev/null +++ b/.opencode/tools/multi-model.ts @@ -0,0 +1,407 @@ +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.`; + }, +});