mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Basic impl with tmux
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.next/
|
||||
.turbo/
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -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 <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 <model>`.
|
||||
13. Return `tmux attach -t <session-name>`.
|
||||
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.
|
||||
@@ -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<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"]);
|
||||
}
|
||||
|
||||
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.`;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user