Compare commits

..
10 Commits
9 changed files with 164 additions and 72 deletions
-52
View File
@@ -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"
}
}
}
}
+73
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-multi-model",
"version": "0.0.5",
"version": "0.0.7",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module",
"main": "./dist/index.js",
+5 -3
View File
@@ -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) {
+18 -16
View File
@@ -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,17 +96,19 @@ 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
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
// 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]);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = chalk.red(`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`);
instructionsArr.push(warningMsg);
}
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
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);
+2
View File
@@ -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,
});
+2
View File
@@ -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. */
+38
View File
@@ -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);
});
});
+25
View File
@@ -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: "" };