mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Impl: tool-npm-package
This commit is contained in:
@@ -1,500 +1,6 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
// Re-export open and close tools for project-level use via relative paths.
|
||||
// For published package usage, change imports to "@username/opencode-multi-model".
|
||||
import { openTool } from "../../src/tools/open";
|
||||
import { closeTool } from "../../src/tools/close";
|
||||
|
||||
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[0]!;
|
||||
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 = `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");
|
||||
},
|
||||
});
|
||||
|
||||
export const __testing_helpers = {
|
||||
shellQuote,
|
||||
normalizeModels,
|
||||
findDuplicates,
|
||||
createWindowBaseName,
|
||||
createWindowPlans,
|
||||
levenshtein,
|
||||
suggestModels,
|
||||
formatInvalidModelError,
|
||||
sanitizeName,
|
||||
runCommand,
|
||||
getWorktreePath,
|
||||
undoWorktree,
|
||||
launchModelInWindow,
|
||||
};
|
||||
export { openTool, closeTool };
|
||||
|
||||
Reference in New Issue
Block a user