Basic impl with tmux

This commit is contained in:
2026-03-12 17:03:04 +05:30
commit 5a4752f894
3 changed files with 540 additions and 0 deletions
@@ -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.
+407
View File
@@ -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.`;
},
});