mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
Add git worktree support
This commit is contained in:
Generated
+52
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<sessionName>/<windowName>`.
|
||||||
|
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/<sanitizedSessionName>/<windowName>`.
|
||||||
|
- **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/<sanitizedSessionName>/<windowName>`.
|
||||||
|
- 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/<branchName>` to check if the branch exists. If it does, fail fast with an error indicating the branch collision.
|
||||||
|
- Run `git worktree add -b <branchName> <worktreePath>` 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 <worktreePath>` and `git branch -D <branchName>`. 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/<sanitizedSessionName> && git worktree prune && git branch -d $(git branch --format='%(refname:short)' --list 'opencode/<sanitizedSessionName>/*')`
|
||||||
|
|
||||||
|
## 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<string[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
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 = {
|
type WindowLaunchPlan = {
|
||||||
model: string;
|
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"]);
|
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<string[]> {
|
||||||
|
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({
|
export default tool({
|
||||||
description: "Launch multiple OpenCode models in tmux",
|
description: "Launch multiple OpenCode models in tmux",
|
||||||
args: {
|
args: {
|
||||||
@@ -283,6 +313,7 @@ export default tool({
|
|||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const sessionName = args.sessionName.trim();
|
const sessionName = args.sessionName.trim();
|
||||||
|
const safeSessionName = sanitizeName(sessionName);
|
||||||
const models = normalizeModels(args.models);
|
const models = normalizeModels(args.models);
|
||||||
|
|
||||||
if (!sessionName) {
|
if (!sessionName) {
|
||||||
@@ -298,6 +329,11 @@ export default tool({
|
|||||||
return `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`;
|
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"]);
|
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
|
||||||
if (!tmuxExists.ok) {
|
if (!tmuxExists.ok) {
|
||||||
return "Error: `tmux` is not installed or not on `PATH`.";
|
return "Error: `tmux` is not installed or not on `PATH`.";
|
||||||
@@ -329,11 +365,46 @@ export default tool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const windowPlans = createWindowPlans(models);
|
const windowPlans = createWindowPlans(models);
|
||||||
const [firstWindow, ...remainingWindows] = windowPlans;
|
|
||||||
const succeededModels: string[] = [];
|
const succeededModels: string[] = [];
|
||||||
const failedModels: 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.
|
for (let i = 0; i < windowPlans.length; i++) {
|
||||||
|
const plan = windowPlans[i];
|
||||||
|
const isFirst = i === 0;
|
||||||
|
|
||||||
|
const worktreePath = getWorktreePath(safeSessionName, plan.windowName);
|
||||||
|
const branchName = `opencode/${safeSessionName}/${plan.windowName}`;
|
||||||
|
|
||||||
|
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.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
const sessionCreateResult = await runCommand([
|
||||||
"tmux",
|
"tmux",
|
||||||
"new-session",
|
"new-session",
|
||||||
@@ -341,23 +412,18 @@ export default tool({
|
|||||||
"-s",
|
"-s",
|
||||||
sessionName,
|
sessionName,
|
||||||
"-n",
|
"-n",
|
||||||
firstWindow.windowName,
|
plan.windowName,
|
||||||
"-c",
|
"-c",
|
||||||
context.directory,
|
worktreePath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!sessionCreateResult.ok) {
|
if (!sessionCreateResult.ok) {
|
||||||
return `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstLaunchResult = await launchModelInWindow(sessionName, firstWindow);
|
|
||||||
if (firstLaunchResult.ok) {
|
|
||||||
succeededModels.push(firstWindow.model);
|
|
||||||
} else {
|
} else {
|
||||||
failedModels.push(`${firstWindow.model} (${firstLaunchResult.stderr || "failed to send launch command"})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const plan of remainingWindows) {
|
|
||||||
const windowCreateResult = await runCommand([
|
const windowCreateResult = await runCommand([
|
||||||
"tmux",
|
"tmux",
|
||||||
"new-window",
|
"new-window",
|
||||||
@@ -367,28 +433,34 @@ export default tool({
|
|||||||
"-n",
|
"-n",
|
||||||
plan.windowName,
|
plan.windowName,
|
||||||
"-c",
|
"-c",
|
||||||
context.directory,
|
worktreePath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!windowCreateResult.ok) {
|
if (!windowCreateResult.ok) {
|
||||||
failedModels.push(`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const launchResult = await launchModelInWindow(sessionName, plan);
|
const launchResult = await launchModelInWindow(sessionName, plan);
|
||||||
if (!launchResult.ok) {
|
if (launchResult.ok) {
|
||||||
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
succeededModels.push(plan.model);
|
succeededModels.push(plan.model);
|
||||||
|
} else {
|
||||||
|
failedModels.push(`${plan.model} (${launchResult.stderr || "failed to send launch command"})`);
|
||||||
|
const undoErrors = await undoWorktree(worktreePath, branchName);
|
||||||
|
if (undoErrors.length > 0) failedModels.push(`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachCommand = `tmux attach -t ${sessionName}`;
|
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({
|
context.metadata({
|
||||||
title: `multi-model ${sessionName}`,
|
title: `multi-model ${sessionName}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
sessionName,
|
safeSessionName,
|
||||||
modelCount: models.length,
|
modelCount: models.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -399,9 +471,14 @@ export default tool({
|
|||||||
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
|
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
|
||||||
`Failed: ${failedModels.join(", ")}.`,
|
`Failed: ${failedModels.join(", ")}.`,
|
||||||
`Attach with \`${attachCommand}\` to inspect the session.`,
|
`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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user