mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 21:52:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bcdc0a90f | ||
|
|
132a15dfc4 | ||
|
|
8bbd741bfa | ||
|
|
0503c474a8 | ||
|
|
d92c9623a8 | ||
|
|
4364910fca | ||
|
|
f956406e9d | ||
|
|
b92fdc692b | ||
|
|
81b25bd348 | ||
|
|
b2621a10de | ||
|
|
fc7feb2095 | ||
|
|
7180569b87 | ||
|
|
f0cc07f513 | ||
|
|
6dd4ee4b4f |
Generated
-52
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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.5.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.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,79 @@ opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
|
||||
|
||||
Priority: CLI flag > Environment variable > Default ("opencode")
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### `open` - Create a new multi-model tmux session
|
||||
|
||||
```bash
|
||||
opencode-multi-model open <session-name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
- `<session-name>` - Name for the tmux session (required)
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-m, --models <models...>` | Model IDs to launch (space-separated) |
|
||||
| `-b, --binary <binary>` | Binary to use (opencode or kilo). Defaults to env var `OPENCODE_MULTI_MODEL_BINARY` or 'opencode' |
|
||||
|
||||
### `close` - Close a multi-model tmux session
|
||||
|
||||
```bash
|
||||
opencode-multi-model close <session-name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
- `<session-name>` - Name of the tmux session to close (required)
|
||||
|
||||
**Options:**
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--keep-worktrees` | Do not remove worktrees and delete branches |
|
||||
| `--no-tags` | Do not create archive tags before deleting branches |
|
||||
| `-f, --force` | Skip confirmation prompts |
|
||||
|
||||
### Global Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-h, --help` | Display help information |
|
||||
| `-V, --version` | Display version number |
|
||||
|
||||
## How it works
|
||||
|
||||
### `open` command
|
||||
|
||||
The `open` command performs the following steps:
|
||||
|
||||
1. **Validation**: Checks that session name andmodels are provided, no duplicate models exist, and verifies git repo, tmux, and binary are available
|
||||
|
||||
2. **Model verification**: Validates models against `opencode models` output to ensure only valid model IDs are used
|
||||
|
||||
3. **Git worktree creation**: For each model, creates:
|
||||
- A git worktree at `~/.local/share/opencode/multi-model/<session>/<model>/`
|
||||
- A branch named `opencode/<session>/<model>`
|
||||
|
||||
4. **Tmux session**: Creates a tmux session with one window per model, each window's working directory set to its corresponding worktree
|
||||
|
||||
5. **Launch**: Sends the launch command to each window to start the binary with the specified model
|
||||
|
||||
### `close` command
|
||||
|
||||
The `close` command performs the following steps:
|
||||
|
||||
1. **Kill tmux session**: Terminates the tmux session if it exists
|
||||
|
||||
2. **Worktree discovery**: Finds all worktrees under `~/.local/share/opencode/multi-model/<session>/`
|
||||
|
||||
3. **Worktree cleanup** (unless `--keep-worktrees`):
|
||||
- Removes each gitworktree
|
||||
- Creates an archive tag (unless `--no-tags`): `archive/<branch>-<timestamp>` for recovery
|
||||
- Deletes the associated branches
|
||||
|
||||
4. **Directory cleanup**: Removes the base session directory
|
||||
|
||||
## Requirements
|
||||
|
||||
- tmux
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.9",
|
||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"bin": {
|
||||
"opencode-multi-model": "./dist/cli.js"
|
||||
"opencode-multi-model": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
|
||||
+5
-3
@@ -44,14 +44,16 @@ 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("--keep-worktrees", "Do not remove worktrees and delete branches", false)
|
||||
.option("--no-tags", "Create archive tags before deleting branches")
|
||||
.option("-f, --force", "Skip confirmation prompts", false)
|
||||
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => {
|
||||
.action(async (sessionName: string, options: { keepWorktrees: boolean; force: boolean; tags: boolean }) => {
|
||||
try {
|
||||
const result = await closeMultiModel({
|
||||
sessionName,
|
||||
cleanupWorktrees: options.cleanupWorktrees,
|
||||
cleanupWorktrees: !options.keepWorktrees,
|
||||
force: options.force,
|
||||
createArchiveTags: options.tags,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
||||
+10
-8
@@ -54,14 +54,14 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
const safeSessionName = sanitizeName(options.sessionName);
|
||||
try {
|
||||
// Check if session exists
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", safeSessionName]);
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
|
||||
|
||||
// Kill tmux session if it exists
|
||||
if (sessionExists) {
|
||||
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
|
||||
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||
instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`));
|
||||
} else {
|
||||
instructionsArr.push(chalk.yellow(`Session '${safeSessionName}' does not exist`));
|
||||
instructionsArr.push(chalk.yellow(`Session '${options.sessionName}' does not exist`));
|
||||
}
|
||||
|
||||
// Get list of worktrees for this session before killing tmux
|
||||
@@ -69,10 +69,10 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
|
||||
// If cleanup requested and not forced, ask for confirmation
|
||||
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
|
||||
instructionsArr.push(chalk.red('The following worktrees and branches will be removed:'));
|
||||
console.log(chalk.red('The following worktrees and branches will be removed:'));
|
||||
worktrees.forEach((wt) => {
|
||||
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
|
||||
instructionsArr.push(msg.trim());
|
||||
console.log(msg.trim());
|
||||
});
|
||||
|
||||
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
|
||||
@@ -96,7 +96,8 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
|
||||
worktreesRemoved.push(worktree.path);
|
||||
|
||||
// Create an archive tag before deleting the branch for safe recovery
|
||||
// Optionally create an archive tag before deleting the branch for safe recovery
|
||||
if (options.createArchiveTags !== false) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
||||
@@ -104,9 +105,10 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
} else {
|
||||
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
|
||||
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
|
||||
instructionsArr.push(warningMsg);
|
||||
}
|
||||
}
|
||||
|
||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||
branchesDeleted.push(worktree.branch);
|
||||
|
||||
@@ -12,12 +12,14 @@ export const closeTool = tool({
|
||||
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)"),
|
||||
createArchiveTags: tool.schema.boolean().default(true).describe("whether to create archive tags before deleting branches (default: true)"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
|
||||
const result = await closeMultiModel({
|
||||
sessionName: args.sessionName,
|
||||
cleanupWorktrees: args.cleanupWorktrees,
|
||||
createArchiveTags: args.createArchiveTags,
|
||||
force: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface MultiModelResult {
|
||||
* Options for closing a multi-model tmux session.
|
||||
*/
|
||||
export interface CloseSessionOptions {
|
||||
/** Whether to create archive tags before deleting branches. */
|
||||
createArchiveTags?: boolean;
|
||||
/** Name of the tmux session to close. */
|
||||
sessionName: string;
|
||||
/** Whether to remove worktrees and delete branches. */
|
||||
|
||||
@@ -220,4 +220,42 @@ describe("closeMultiModel", () => {
|
||||
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
|
||||
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`);
|
||||
});
|
||||
|
||||
test("tmux commands use sessionName, not safeSessionName", async () => {
|
||||
const unsanitized = "my session!";
|
||||
const sanitized = "my-session";
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = { ok: true, stdout: "", stderr: "" };
|
||||
// No worktrees
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: false, force: false });
|
||||
expect(result.success).toBe(true);
|
||||
// Verify tmux commands used unsanitized name
|
||||
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
|
||||
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
|
||||
// Ensure sanitized name not used in tmux commands
|
||||
expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false);
|
||||
});
|
||||
|
||||
test("skip archive tags when flag disabled", async () => {
|
||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
||||
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" };
|
||||
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true, createArchiveTags: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.cleanupPerformed).toBe(true);
|
||||
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
||||
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
||||
expect(result.tagsCreated?.length ?? 0).toBe(0);
|
||||
// Ensure no tag command was executed
|
||||
expect(executedCommands.some(cmd => cmd.startsWith("git tag"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -232,6 +232,31 @@ describe("multi-model launch", () => {
|
||||
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true);
|
||||
});
|
||||
|
||||
test("uses sessionName for tmux commands, not safeSessionName", async () => {
|
||||
const unsanitized = "my awesome session!!";
|
||||
const sanitized = "my-awesome-session";
|
||||
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`] = { ok: false, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`] = { ok: true, stdout: "", stderr: "" };
|
||||
mockCommandResponses[`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
const result = await openMultiModel({
|
||||
sessionName: unsanitized,
|
||||
models: ["openai/gpt-5.2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// tmux commands should use unsanitized name
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux has-session -t ${unsanitized}`))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux new-session -d -s ${unsanitized}`))).toBe(true);
|
||||
expect(executedCommands.some(c => c.startsWith(`tmux send-keys -t ${unsanitized}:`))).toBe(true);
|
||||
// sanitized name should NOT appear in tmux commands
|
||||
expect(executedCommands.some(c => c.includes(`-t ${sanitized}`))).toBe(false);
|
||||
// git branches and paths should use sanitized name
|
||||
expect(executedCommands.some(c => c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`))).toBe(true);
|
||||
});
|
||||
|
||||
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
|
||||
mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" };
|
||||
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
|
||||
|
||||
Reference in New Issue
Block a user