--- 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**: 1. **OpenCode Plugin** - Installed and loaded via plugin system 2. **Standalone CLI** - Direct command-line usage without OpenCode 3. **Project Tool** - Re-exported in `.opencode/tools/` for local testing ## Key Requirements 1. **Package as OpenCode Plugin** - Users install via `"plugin"` field in config 2. **Custom npm scope** - Package name will be user-defined (e.g., `@username/opencode-multi-model`) 3. **Configurable binary** - Support both `opencode` and `kilo` binaries via environment variable/config 4. **Standalone CLI** - Can be used directly via `npx @username/opencode-multi-model ...` 5. **Project-level tool** - Re-export tool in `.opencode/tools/multi-model.ts` for local development 6. **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/plugin` to 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: ```json // 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 ```typescript // 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[]; } ``` ### 3. Refactor Utils to Separate File ```typescript // src/core/utils.ts // All helper functions extracted from current multi-model.ts export function runCommand(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { // Implementation using Bun.$ } export function shellQuote(str: string): string { // POSIX-safe shell escaping } export function normalizeModels(models: unknown): string[] { // Input sanitization } export function findDuplicates(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 { // Error message formatting } export function sanitizeName(name: string): string { // Session name sanitization } export function getWorktreePath(sessionName: string, windowName: string): string { // Worktree path generation const homedir = process.env.HOME || process.env.USERPROFILE || "/tmp"; return `${homedir}/.local/share/opencode/multi-model/${sessionName}/${windowName}`; } export interface WorktreeInfo { path: string; branch: string; } export async function getWorktreesForSession(sessionName: string): Promise { // Get all worktrees and filter for this session const { stdout } = await runCommand("git worktree list --porcelain"); const worktrees: WorktreeInfo[] = []; const homedir = process.env.HOME || process.env.USERPROFILE || "/tmp"; const sessionPath = `${homedir}/.local/share/opencode/multi-model/${sessionName}`; let currentWorktree: Partial = {}; 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 { // 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 ```typescript // src/core/launch.ts import type { MultiModelOptions, MultiModelResult } from "../types"; import { runCommand, shellQuote, normalizeModels, findDuplicates, createWindowPlans, suggestModels, formatInvalidModelError, sanitizeName, getWorktreePath, undoWorktree, getBinaryName } from "./utils"; export async function launchMultiModel(options: MultiModelOptions): Promise { // 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 ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*') ` }; } ``` ### 5. Create Core Close Logic ```typescript // src/core/close.ts import type { CloseSessionOptions, CloseSessionResult } from "../types"; import { runCommand, getWorktreePath, getWorktreesForSession, sanitizeName } from "./utils"; import * as readline from "readline"; async function promptUser(question: string): Promise { 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 { const worktreesRemoved: string[] = []; const branchesDeleted: string[] = []; try { // Check if session exists const { exitCode } = await runCommand(`tmux has-session -t ${options.sessionName} 2>/dev/null`); const sessionExists = exitCode === 0; // 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); 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 = `~/.local/share/opencode/multi-model/${options.sessionName}`; try { await runCommand(`rmdir ${worktreeBase} 2>/dev/null || true`); } catch { // Ignore errors if directory not empty } cleanupPerformed = worktreesRemoved.length > 0; } return { success: true, sessionName: options.sessionName, cleanupPerformed, worktreesRemoved, branchesDeleted }; } catch (error) { return { success: false, sessionName: options.sessionName, error: String(error), worktreesRemoved, branchesDeleted }; } } ``` ### 6. Create Core Index ```typescript // src/core/index.ts export { launchMultiModel } from "./launch"; export { closeMultiModel } from "./close"; export * from "./utils"; export * from "../types"; ``` ### 7. Create Open Tool Definition ```typescript // 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 ```typescript // 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.`; } return { content: `Session "${result.sessionName}" has been closed.${details}` }; } }); ``` ### 9. Create Plugin Entry Point ```typescript // 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) ```typescript // src/cli.ts #!/usr/bin/env node 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("", "Name for the tmux session") .option("-m, --models ", "Model IDs to launch (space-separated)", []) .option("-b, --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("", "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.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 ```typescript // .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 ```json // 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 ```json // package.json scripts { "scripts": { "build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm", "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"` and `import { 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 ```markdown # 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 ```bash # 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 (for development) ```typescript // .opencode/tools/multi-model.ts import { openTool, closeTool } from "@username/opencode-multi-model" 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:** ```bash # 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:** ```bash # Just close the tmux session opencode-multi-model close my-session # Close and cleanup worktrees opencode-multi-model close my-session --cleanup-worktrees ``` **Help:** ```bash 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:** ```bash export OPENCODE_MULTI_MODEL_BINARY=kilo ``` **Via OpenCode config:** ```json { "plugin": ["@username/opencode-multi-model"], "multiModelBinary": "kilo" } ``` **Via CLI flag:** ```bash 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: ```typescript // .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 entry - `tsconfig.json` - TypeScript configuration - `src/types.ts` - Shared TypeScript types - `src/index.ts` - Plugin entry point (exports open and close tools) - `src/cli.ts` - CLI entry point with open/close subcommands - `src/core/index.ts` - Core exports - `src/core/utils.ts` - Helper functions - `src/core/launch.ts` - Launch session logic - `src/core/close.ts` - Close session logic - `src/tools/open.ts` - Open tool definition - `src/tools/close.ts` - Close tool definition - `tests/multi-model.test.ts` - Tests (copy from `.opencode/multi-model.test.ts`) - `README.md` - Installation and usage docs - `LICENSE` - 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 1. **Build**: Run `bun run build` to compile TypeScript 2. **Test**: Run `bun test` in the new package directory 3. **CLI Test**: ```bash # Test open node dist/cli.js open test-session -m openai/gpt-4o # Test close node dist/cli.js close test-session # Test env var OPENCODE_MULTI_MODEL_BINARY=kilo node dist/cli.js open test-session -m openai/gpt-4o ``` 4. **Plugin Test**: - Link package locally with `bun link` - Add to test project config - Verify both open and close tools work 5. **Project Tool Test**: - Update `.opencode/tools/multi-model.ts` to re-export - Run from project directory 6. **Publish**: Run `npm publish --access public` (or `bun publish`) ## Usage Examples ### End User - Plugin ```json // opencode.json { "plugin": ["@username/opencode-multi-model"] } ``` Then use via OpenCode interface with `multi-model-open` and `multi-model-close` tools. ### End User - CLI ```bash # 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 ```typescript // .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: 1. CLI flag (`-b, --binary`) 2. OpenCode config (`multiModelBinary`) 3. Environment variable (`OPENCODE_MULTI_MODEL_BINARY`) 4. 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` (or `open` in CLI) - Creates sessions - `multi-model-close` (or `close` in CLI) - Closes sessions This provides a cleaner API and makes it easier for users to manage sessions.