26 KiB
model
| model |
|---|
| ollama/kimi-k2.5:cloud |
Plan: Multi-format multi-model tool - OpenCode Plugin + CLI + Project Tool
Overview
Convert the existing .opencode/tools/multi-model.ts into a sharable npm package that users can install and configure. The tool will support three usage formats:
- OpenCode Plugin - Installed and loaded via plugin system
- Standalone CLI - Direct command-line usage without OpenCode
- Project Tool - Re-exported in
.opencode/tools/for local testing
Key Requirements
- Package as OpenCode Plugin - Users install via
"plugin"field in config - Custom npm scope - Package name will be user-defined (e.g.,
@username/opencode-multi-model) - Configurable binary - Support both
opencodeandkilobinaries via environment variable/config - Standalone CLI - Can be used directly via
npx @username/opencode-multi-model ... - Project-level tool - Re-export tool in
.opencode/tools/multi-model.tsfor local development - Shared core logic - Single implementation across all three formats to avoid duplication
Architecture
Current State
- Tool lives in
.opencode/tools/multi-model.ts - Uses
@opencode-ai/pluginto define custom tool - Already has comprehensive tests in
.opencode/multi-model.test.ts
Target State
├── src/
│ ├── index.ts # Plugin entry point - exports open and close tools
│ ├── cli.ts # CLI entry point with open/close subcommands
│ ├── core/
│ │ ├── index.ts # Core exports (launchMultiModel, closeMultiModel)
│ │ ├── launch.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ └── utils.ts # Helper functions (shellQuote, levenshtein, etc.)
│ ├── tools/
│ │ ├── open.ts # Open tool definition
│ │ └── close.ts # Close tool definition
│ └── types.ts # Shared TypeScript types
├── tests/
│ └── multi-model.test.ts # Tests (refactored from existing)
├── package.json # NPM package config
├── tsconfig.json # TypeScript config
├── README.md # Documentation
└── LICENSE # License
Implementation Steps
1. Create Plugin Structure
Create new package with proper TypeScript configuration:
// package.json
{
"name": "@username/opencode-multi-model",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"opencode-multi-model": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./core": {
"import": "./dist/core/index.js",
"types": "./dist/core/index.d.ts"
}
},
"dependencies": {
"@opencode-ai/plugin": "^1.2.26",
"commander": "^12.0.0"
},
"devDependencies": {
"bun-types": "^1.0.0",
"typescript": "^5.0.0"
}
}
2. Create Shared Types
// src/types.ts
export interface MultiModelOptions {
sessionName: string;
models: string[];
binaryName?: string; // 'opencode' or 'kilo'
}
export interface MultiModelResult {
success: boolean;
sessionName: string;
windows?: string[];
error?: string;
instructions?: string; // Connection and cleanup instructions
}
export interface CloseSessionOptions {
sessionName: string;
cleanupWorktrees?: boolean; // Remove worktrees and branches
force?: boolean; // Skip confirmation prompts
}
export interface CloseSessionResult {
success: boolean;
sessionName: string;
error?: string;
cleanupPerformed?: boolean;
worktreesRemoved?: string[];
branchesDeleted?: string[];
tagsCreated?: string[];
warnings?: string[];
}
3. Refactor Utils to Separate File
// src/core/utils.ts
// All helper functions extracted from current multi-model.ts
export type CommandResult = {
ok: boolean;
stdout: string;
stderr: string;
};
export async function runCommand(parts: string[]): Promise<CommandResult> {
// Implementation using Bun.$
}
export function shellQuote(str: string): string {
// POSIX-safe shell escaping
}
export function normalizeModels(models: unknown): string[] {
// Input sanitization
}
export function findDuplicates<T>(arr: T[]): T[] {
// Detect duplicates
}
export function createWindowBaseName(model: string): string {
// Generate tmux-safe window names
}
export function createWindowPlans(models: string[]): Array<{ model: string; windowName: string }> {
// Create unique window plans with collision handling
}
export function levenshtein(a: string, b: string): number {
// Edit distance calculation for fuzzy matching
}
export function suggestModels(input: string, validModels: string[], maxSuggestions?: number): string[] {
// Fuzzy matching and suggestion limits
}
export function formatInvalidModelError(invalidModels: string[], suggestions: Record<string, string[]>): string {
// Error message formatting
}
export function sanitizeName(name: string): string {
// Session name sanitization
}
export function getSessionPath(sessionName: string): string {
const homedir = process.env.HOME || process.env.USERPROFILE || "/tmp";
return `${homedir}/.local/share/opencode/multi-model/${sessionName}`;
}
export function getWorktreePath(sessionName: string, windowName: string): string {
// Worktree path generation
return `${getSessionPath(sessionName)}/${windowName}`;
}
export interface WorktreeInfo {
path: string;
branch: string;
}
export async function getWorktreesForSession(sessionName: string): Promise<WorktreeInfo[]> {
// Get all worktrees and filter for this session
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sessionName);
let currentWorktree: Partial<WorktreeInfo> = {};
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
currentWorktree = {
path: line.slice(9)
};
} else if (line.startsWith("branch ")) {
currentWorktree.branch = line.slice(7);
}
}
// Add last worktree
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
// Filter for session-specific worktrees
return worktrees.filter(wt => wt.path.startsWith(sessionPath));
}
export function undoWorktree(worktreePath: string): Promise<void> {
// Cleanup helper for failed operations
}
export function getBinaryName(context?: { config?: { multiModelBinary?: string } }): string {
// Priority: context config > env var > default
return context?.config?.multiModelBinary ||
process.env.OPENCODE_MULTI_MODEL_BINARY ||
"opencode";
}
4. Create Core Launch Logic
// src/core/launch.ts
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand, shellQuote, normalizeModels, findDuplicates,
createWindowPlans, suggestModels, formatInvalidModelError,
sanitizeName, getWorktreePath, getSessionPath, undoWorktree, getBinaryName
} from "./utils";
export async function launchMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
// Core implementation from current multi-model.ts
// All the validation, tmux setup, worktree creation, etc.
const safeSessionName = sanitizeName(options.sessionName);
// Returns result with instructions for connecting and cleanup
return {
success: true,
sessionName: options.sessionName,
windows: [], // populated with actual window names
instructions: `
Session "${options.sessionName}" created successfully!
To attach to the session:
tmux attach -t ${options.sessionName}
To list all windows:
tmux list-windows -t ${options.sessionName}
To close the session and cleanup:
opencode-multi-model close ${options.sessionName}
Or manually:
tmux kill-session -t ${options.sessionName}
rm -rf ${getSessionPath(safeSessionName)} && git worktree prune
git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')
`
};
}
5. Create Core Close Logic
// src/core/close.ts
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
import * as readline from "readline";
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const warnings: string[] = [];
try {
// Check if session exists
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(options.sessionName);
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(`\nThe following worktrees and branches will be removed:`);
worktrees.forEach(wt => console.log(` - ${wt.path} (branch: ${wt.branch})`));
const answer = await promptUser("\nDo you want to proceed? (y/N): ");
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
return {
success: false,
sessionName: options.sessionName,
error: "Cleanup cancelled by user"
};
}
}
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
}
let cleanupPerformed = false;
// Cleanup worktrees and optionally branches
if (options.cleanupWorktrees) {
for (const worktree of worktrees) {
try {
// Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path);
// Create an archive tag before deleting the branch for safe recovery
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
warnings.push(warningMsg);
console.warn(`Warning: ${warningMsg}`);
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
}
}
// Also remove the base directory if empty
const worktreeBase = getSessionPath(options.sessionName);
try {
await runCommand(["rmdir", worktreeBase]);
} catch {
// Ignore errors if directory not empty
}
cleanupPerformed = worktreesRemoved.length > 0;
}
return {
success: true,
sessionName: options.sessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings
};
} catch (error) {
return {
success: false,
sessionName: options.sessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
warnings
};
}
}
6. Create Core Index
// src/core/index.ts
export { launchMultiModel } from "./launch";
export { closeMultiModel } from "./close";
export * from "./utils";
export * from "../types";
7. Create Open Tool Definition
// src/tools/open.ts
import { tool, type ToolContext } from "@opencode-ai/plugin";
import { launchMultiModel } from "../core/launch";
import { getBinaryName } from "../core/utils";
export const openTool = tool({
name: "multi-model-open",
description: "Launch multiple OpenCode models in tmux session",
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: ToolContext) {
const binaryName = getBinaryName(context);
const result = await launchMultiModel({
sessionName: args.sessionName,
models: args.models,
binaryName
});
if (!result.success) {
throw new Error(result.error);
}
return {
content: result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`
};
}
});
8. Create Close Tool Definition
// src/tools/close.ts
import { tool, type ToolContext } from "@opencode-ai/plugin";
import { closeMultiModel } from "../core/close";
export const closeTool = tool({
name: "multi-model-close",
description: "Close a multi-model tmux session and optionally cleanup worktrees and branches",
args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to close"),
cleanupWorktrees: tool.schema.boolean().default(true).describe("whether to remove worktrees and delete branches (default: true)"),
},
async execute(args, context: ToolContext) {
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
const result = await closeMultiModel({
sessionName: args.sessionName,
cleanupWorktrees: args.cleanupWorktrees,
force: true, // Skip confirmation in plugin mode
});
if (!result.success) {
throw new Error(result.error);
}
let details = "";
if (result.cleanupPerformed) {
details += ` Removed ${result.worktreesRemoved?.length || 0} worktrees.`;
if (result.tagsCreated && result.tagsCreated.length > 0) {
details += ` Created backup tags: ${result.tagsCreated.join(", ")}.`;
}
if (result.warnings && result.warnings.length > 0) {
details += ` Warnings: ${result.warnings.join("; ")}.`;
}
}
return {
content: `Session "${result.sessionName}" has been closed.${details}`
};
}
});
9. Create Plugin Entry Point
// src/index.ts
import type { Plugin } from "@opencode-ai/plugin";
import { openTool } from "./tools/open";
import { closeTool } from "./tools/close";
export const OpenCodeMultiModelPlugin: Plugin = async (ctx) => {
return {
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
},
};
};
export { openTool, closeTool };
export * from "./core/index";
10. Create CLI Entry Point (with env support)
// src/cli.ts
#!/usr/bin/env bun
import { Command } from "commander";
import { launchMultiModel } from "./core/launch";
import { closeMultiModel } from "./core/close";
import { getBinaryName } from "./core/utils";
const program = new Command();
program
.name("opencode-multi-model")
.description("Launch multiple OpenCode models in tmux sessions")
.version("1.0.0");
// Open subcommand
program
.command("open")
.description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session")
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", [])
.option("-b, --binary <binary>", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'")
.action(async (sessionName, options) => {
try {
// CLI flag takes precedence, then env var via getBinaryName()
const binaryName = options.binary || getBinaryName();
const result = await launchMultiModel({
sessionName,
models: options.models,
binaryName
});
if (result.success) {
console.log(result.instructions || `✓ Created session: ${result.sessionName}`);
} else {
console.error(`✗ Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`✗ Error: ${error}`);
process.exit(1);
}
});
// Close subcommand
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close")
.option("-c, --cleanup-worktrees", "Remove worktrees and delete branches", true)
.option("-f, --force", "Skip confirmation prompts", false)
.action(async (sessionName, options) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: options.cleanupWorktrees,
force: options.force,
});
if (result.success) {
console.log(`✓ Closed session: ${result.sessionName}`);
if (result.cleanupPerformed) {
console.log(` Removed ${result.worktreesRemoved?.length || 0} worktrees`);
if (result.tagsCreated && result.tagsCreated.length > 0) {
console.log(` Created backup tags: ${result.tagsCreated.join(", ")}`);
}
if (result.warnings && result.warnings.length > 0) {
console.log(` Warnings: ${result.warnings.join("; ")}`);
}
if (result.branchesDeleted && result.branchesDeleted.length > 0) {
console.log(` Deleted ${result.branchesDeleted.length} branches`);
}
}
} else {
console.error(`✗ Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`✗ Error: ${error}`);
process.exit(1);
}
});
program.parse();
11. Create Project-Level Tool Re-export
// .opencode/tools/multi-model.ts
// Re-export both open and close tools for project-level use
import { openTool } from "../../src/tools/open";
import { closeTool } from "../../src/tools/close";
export { openTool, closeTool };
12. Add Build Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
13. Add Build Script and Tests
// package.json scripts
{
"scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm --experimental-dts",
"test": "bun test",
"prepublishOnly": "bun run build"
}
}
Tests: Copy .opencode/multi-model.test.ts to tests/multi-model.test.ts and update imports:
- Change
import multiModelTool, { __testing_helpers } from "./tools/multi-model" - To
import { launchMultiModel, closeMultiModel } from "../src/core"andimport { runCommand, ... } from "../src/core/utils"
The existing tests should work with minimal changes since the core logic remains the same.
14. Create README with Usage Instructions
# opencode-multi-model
Launch multiple AI models in tmux sessions. Available as:
- OpenCode Plugin
- Standalone CLI
- Project-level tool
## Installation
### As OpenCode Plugin
Add to your OpenCode config (`opencode.json`):
```json
{
"plugin": ["@username/opencode-multi-model"]
}
As Standalone CLI
# Using npx (no install)
npx @username/opencode-multi-model open my-session -m openai/gpt-4o anthropic/claude-3-5-sonnet
# Or install globally
npm install -g @username/opencode-multi-model
opencode-multi-model open my-session -m openai/gpt-4o
As Project Tool
If you installed it via NPM and want to use it as a project tool, import it from the package:
// .opencode/tools/multi-model.ts
import { openTool, closeTool } from "@username/opencode-multi-model"
export { openTool, closeTool }
If you are developing this package locally and want to test it in a project without publishing, import via relative paths:
// .opencode/tools/multi-model.ts
import { openTool } from "../../src/tools/open"
import { closeTool } from "../../src/tools/close"
export { openTool, closeTool }
Usage
Plugin Usage
Open a session:
Use multi-model-open tool:
sessionName: my-session, models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"]
Close a session:
Use multi-model-close tool:
sessionName: my-session, cleanupWorktrees: true
CLI Usage
Open a session:
# Basic usage
opencode-multi-model open my-session -m openai/gpt-4o anthropic/claude-3-5-sonnet
# With custom binary (via flag)
opencode-multi-model open my-session -m openai/gpt-4o -b kilo
# With custom binary (via env)
export OPENCODE_MULTI_MODEL_BINARY=kilo
opencode-multi-model open my-session -m openai/gpt-4o
Close a session:
# Just close the tmux session
opencode-multi-model close my-session
# Close and cleanup worktrees
opencode-multi-model close my-session --cleanup-worktrees
Help:
opencode-multi-model --help
opencode-multi-model open --help
opencode-multi-model close --help
Configuration
Custom Binary
By default uses opencode. To use kilo instead:
Via environment variable:
export OPENCODE_MULTI_MODEL_BINARY=kilo
Via OpenCode config:
{
"plugin": ["@username/opencode-multi-model"],
"multiModelBinary": "kilo"
}
Via CLI flag:
opencode-multi-model open my-session -m openai/gpt-4o -b kilo
Priority: CLI flag > OpenCode config > Environment variable > Default ("opencode")
Requirements
- tmux
- git
- opencode or kilo binary
Development
For local development and testing without publishing:
// .opencode/tools/multi-model.ts
import { openTool } from "../../src/tools/open"
import { closeTool } from "../../src/tools/close"
export { openTool, closeTool }
This allows testing the tool without publishing to npm.
Files to Create/Modify
New Files
package.json- NPM package configuration with CLI bin entrytsconfig.json- TypeScript configurationsrc/types.ts- Shared TypeScript typessrc/index.ts- Plugin entry point (exports open and close tools)src/cli.ts- CLI entry point with open/close subcommandssrc/core/index.ts- Core exportssrc/core/utils.ts- Helper functionssrc/core/launch.ts- Launch session logicsrc/core/close.ts- Close session logicsrc/tools/open.ts- Open tool definitionsrc/tools/close.ts- Close tool definitiontests/multi-model.test.ts- Tests (copy from.opencode/multi-model.test.ts)README.md- Installation and usage docsLICENSE- License file
Modified Files
.opencode/tools/multi-model.ts- Re-export from package (for project-level use)
Delete (after migration)
.opencode/multi-model.test.ts- After copying to new package
Verification
- Build: Run
bun run buildto compile TypeScript - Test: Run
bun testin the new package directory - CLI Test:
# Test open bun dist/cli.js open test-session -m openai/gpt-4o # Test close bun dist/cli.js close test-session # Test env var OPENCODE_MULTI_MODEL_BINARY=kilo bun dist/cli.js open test-session -m openai/gpt-4o - Plugin Test:
- Link package locally with
bun link - Add to test project config
- Verify both open and close tools work
- Link package locally with
- Project Tool Test:
- Update
.opencode/tools/multi-model.tsto re-export - Run from project directory
- Update
- Publish: Run
npm publish --access public(orbun publish)
Usage Examples
End User - Plugin
// opencode.json
{
"plugin": ["@username/opencode-multi-model"]
}
Then use via OpenCode interface with multi-model-open and multi-model-close tools.
End User - CLI
# Install globally
npm install -g @username/opencode-multi-model
# Open session
opencode-multi-model open compare-session -m openai/gpt-4o anthropic/claude-3-opus google/gemini-pro
# Close session with cleanup
opencode-multi-model close compare-session --cleanup-worktrees
Developer - Project Tool
// .opencode/tools/multi-model.ts
// For testing local changes before publishing
import { openTool } from "../../src/tools/open"
import { closeTool } from "../../src/tools/close"
export { openTool, closeTool }
This allows testing the tool without publishing to npm.
Key Design Decisions
1. Utils in Separate File
All helper functions (shellQuote, levenshtein, etc.) are in src/core/utils.ts for better organization and testability.
2. CLI Honors Environment Variable
The CLI now properly checks OPENCODE_MULTI_MODEL_BINARY environment variable via the getBinaryName() function. Priority order:
- CLI flag (
-b, --binary) - OpenCode config (
multiModelBinary) - Environment variable (
OPENCODE_MULTI_MODEL_BINARY) - Default (
"opencode")
3. Detailed Return Instructions
Both the tool and CLI return detailed instructions for:
- How to attach to the tmux session
- How to list windows
- How to close the session
- How to cleanup worktrees
4. Open/Close Command Structure
The tool is split into two commands:
multi-model-open(oropenin CLI) - Creates sessionsmulti-model-close(orclosein CLI) - Closes sessions
This provides a cleaner API and makes it easier for users to manage sessions.