Files
opencode-multi-model/.opencode/plans/archive/1773656570427-mighty-tiger.md
2026-03-19 19:12:50 +05:30

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:

  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:

// 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" 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

# 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 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:
    # 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
    
  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

// 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:

  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.