diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..d9fff74 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,52 @@ +{ + "name": "opencode-multi-model-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-multi-model-tools", + "dependencies": { + "@opencode-ai/plugin": "1.2.24" + }, + "devDependencies": { + "@types/node": "^25.4.0" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.2.24", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.2.24", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.2.24", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.opencode/plans/archive/1773315693260-eager-orchid.md b/.opencode/plans/archive/1773315693260-eager-orchid.md new file mode 100644 index 0000000..2290557 --- /dev/null +++ b/.opencode/plans/archive/1773315693260-eager-orchid.md @@ -0,0 +1,89 @@ +--- +llm: google/gemini-3.1-pro-preview +status: draft +--- + +# Multi-model git worktree support plan + +## Goal + +Enhance the `multi-model` tool to: +1. Create a unique git worktree for each session + modelName. +2. Store worktree folders inside `~/.local/share/opencode/multi-model//`. +3. Initialize `opencode` with that specific worktree to avoid conflicts. + +## Implementation Details + +- **Target file:** `.opencode/tools/multi-model.ts` +- **Path Sanitization:** + - Sanitize `sessionName` (e.g., replace non-alphanumeric with hyphens) to ensure safe paths and branch names. `windowName` is already sanitized by existing code. +- **Worktree Path Construction:** + - `worktreePath`: `~/.local/share/opencode/multi-model//`. +- **Git Repository Check:** + - Run `git rev-parse --is-inside-work-tree` at the start of `execute`. + - If false, gracefully fail with an error `multi-model requires a git repository`. +- **Creating Worktrees:** + - Iterate through `windowPlans` to create tmux windows: + - Generate `worktreePath`. + - Generate `branchName` as `opencode//`. + - Handle edge cases (Fail Fast approach): + - Run `fs.existsSync(worktreePath)` to check if the path exists. If it does, fail fast with an error indicating the path collision. + - Run `git show-ref --verify --quiet refs/heads/` to check if the branch exists. If it does, fail fast with an error indicating the branch collision. + - Run `git worktree add -b ` to create the new branch and worktree. + - In `tmux new-session` and `tmux new-window`, pass the newly created `worktreePath` to the `-c` argument instead of `context.directory`. +- **Cleanup Strategy:** + - On launch failure: If `tmux` or `opencode` launch fails *after* worktree creation, run `git worktree remove -f ` and `git branch -D `. Keep track of successful and failed cleanups (undos) and report them in the tool output if the undo process fails. + - On success: Output a command snippet to the user on how to clean up the session's worktrees and branches when done: + `rm -rf ~/.local/share/opencode/multi-model/ && git worktree prune && git branch -d $(git branch --format='%(refname:short)' --list 'opencode//*')` + +## Non-obvious Code Blocks + +**Path Sanitization & Generation (Example):** +```ts +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; + +function sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} + +function getWorktreePath(safeSession: string, windowName: string): string { + return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName); +} +``` + +**Creating Worktree (Example):** +```ts +const safeSession = sanitizeName(sessionName); +const branchName = `opencode/${safeSession}/${plan.windowName}`; + +if (fs.existsSync(worktreePath)) { + return `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`; +} + +const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]); +if (branchExists.ok) { + return `Error: Branch '${branchName}' already exists.`; +} + +const worktreeCmd = ["git", "worktree", "add", "-b", branchName, worktreePath]; +const result = await runCommand(worktreeCmd); + +if (!result.ok) { + return `Error: Failed to create worktree for ${plan.model}: ${result.stderr}`; +} +``` + +**Cleanup on Failure (Example):** +```ts +async function undoWorktree(worktreePath: string, branchName: string): Promise { + const errors: string[] = []; + const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]); + if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`); + + const branchRes = await runCommand(["git", "branch", "-D", branchName]); + if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`); + return errors; +} +``` diff --git a/.opencode/tools/multi-model.ts b/.opencode/tools/multi-model.ts index a083b34..c72bb32 100644 --- a/.opencode/tools/multi-model.ts +++ b/.opencode/tools/multi-model.ts @@ -1,4 +1,7 @@ import { tool } from "@opencode-ai/plugin"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; type WindowLaunchPlan = { model: string; @@ -272,6 +275,33 @@ async function launchModelInWindow(sessionName: string, plan: WindowLaunchPlan): return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]); } +/** + * Sanitizes a session name to be safe for paths and branch names. + */ +function sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} + +/** + * Generates the full absolute path for a worktree. + */ +function getWorktreePath(safeSession: string, windowName: string): string { + return path.join(os.homedir(), ".local/share/opencode/multi-model", safeSession, windowName); +} + +/** + * Removes a git worktree and deletes its branch on failure. + */ +async function undoWorktree(worktreePath: string, branchName: string): Promise { + const errors: string[] = []; + const removeRes = await runCommand(["git", "worktree", "remove", "-f", worktreePath]); + if (!removeRes.ok) errors.push(`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`); + + const branchRes = await runCommand(["git", "branch", "-D", branchName]); + if (!branchRes.ok) errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`); + return errors; +} + export default tool({ description: "Launch multiple OpenCode models in tmux", args: { @@ -283,6 +313,7 @@ export default tool({ }, async execute(args, context) { const sessionName = args.sessionName.trim(); + const safeSessionName = sanitizeName(sessionName); const models = normalizeModels(args.models); if (!sessionName) { @@ -298,6 +329,11 @@ export default tool({ return `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`; } + const gitRepoCheck = await runCommand(["git", "rev-parse", "--is-inside-work-tree"]); + if (!gitRepoCheck.ok) { + return "Error: `multi-model` requires a git repository."; + } + const tmuxExists = await runCommand(["command", "-v", "tmux"]); if (!tmuxExists.ok) { return "Error: `tmux` is not installed or not on `PATH`."; @@ -329,66 +365,102 @@ export default tool({ } 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, - ]); + for (let i = 0; i < windowPlans.length; i++) { + const plan = windowPlans[i]; + const isFirst = i === 0; - if (!sessionCreateResult.ok) { - return `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; - } + const worktreePath = getWorktreePath(safeSessionName, plan.windowName); + const branchName = `opencode/${safeSessionName}/${plan.windowName}`; - 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"})`); - } + if (fs.existsSync(worktreePath)) { + if (!isFirst) { + failedModels.push(`${plan.model} (Worktree path already exists at ${worktreePath})`); + continue; + } else { + return `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`; + } + } - for (const plan of remainingWindows) { - const windowCreateResult = await runCommand([ - "tmux", - "new-window", - "-d", - "-t", - sessionName, - "-n", - plan.windowName, - "-c", - context.directory, - ]); + const branchExists = await runCommand(["git", "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]); + if (branchExists.ok) { + if (!isFirst) { + failedModels.push(`${plan.model} (Branch '${branchName}' already exists)`); + continue; + } else { + return `Error: Branch '${branchName}' already exists.`; + } + } - if (!windowCreateResult.ok) { - failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`); - continue; + const worktreeResult = await runCommand(["git", "worktree", "add", "-b", branchName, worktreePath]); + if (!worktreeResult.ok) { + if (!isFirst) { + failedModels.push(`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`); + continue; + } else { + return `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`; + } + } + + if (isFirst) { + const sessionCreateResult = await runCommand([ + "tmux", + "new-session", + "-d", + "-s", + sessionName, + "-n", + plan.windowName, + "-c", + worktreePath, + ]); + + if (!sessionCreateResult.ok) { + const undoErrors = await undoWorktree(worktreePath, branchName); + let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`; + if (undoErrors.length > 0) errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`; + return errorMsg; + } + } else { + const windowCreateResult = await runCommand([ + "tmux", + "new-window", + "-d", + "-t", + sessionName, + "-n", + plan.windowName, + "-c", + worktreePath, + ]); + + if (!windowCreateResult.ok) { + failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`); + const undoErrors = await undoWorktree(worktreePath, branchName); + if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); + continue; + } } const launchResult = await launchModelInWindow(sessionName, plan); - if (!launchResult.ok) { + if (launchResult.ok) { + succeededModels.push(plan.model); + } else { failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`); - continue; + const undoErrors = await undoWorktree(worktreePath, branchName); + if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`); } - - succeededModels.push(plan.model); } const attachCommand = `tmux attach -t ${sessionName}`; + const cleanupCommand = `rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`; + context.metadata({ title: `multi-model ${sessionName}`, metadata: { - sessionName, + safeSessionName, modelCount: models.length, }, }); @@ -399,9 +471,14 @@ export default tool({ `Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`, `Failed: ${failedModels.join(", ")}.`, `Attach with \`${attachCommand}\` to inspect the session.`, - ].join(" "); + `Cleanup with \n\`${cleanupCommand}\`\nwhen done.` + ].join("\n"); } - return `Use \`${attachCommand}\` to join session.`; + return [ + `Use \`${attachCommand}\` to join session.`, + `When finished, clean up worktrees with:`, + `\`${cleanupCommand}\`` + ].join("\n"); }, });