mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890749aa9a | ||
|
|
8368cd8015 | ||
|
|
8bbd741bfa | ||
|
|
0503c474a8 | ||
|
|
d92c9623a8 | ||
|
|
4364910fca | ||
|
|
f956406e9d | ||
|
|
b92fdc692b | ||
|
|
81b25bd348 | ||
|
|
b2621a10de |
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")
|
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
|
## Requirements
|
||||||
|
|
||||||
- tmux
|
- tmux
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-multi-model",
|
"name": "opencode-multi-model",
|
||||||
"version": "0.0.6",
|
"version": "0.0.8",
|
||||||
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
+7
-2
@@ -30,8 +30,13 @@ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Publish to npm
|
# Prompt for OTP
|
||||||
npm publish
|
read -p "Enter npm OTP (leave blank if not using 2FA): " otp
|
||||||
|
if [[ -n "$otp" ]]; then
|
||||||
|
npm publish --otp "$otp"
|
||||||
|
else
|
||||||
|
npm publish
|
||||||
|
fi
|
||||||
|
|
||||||
# Push git commits and tags
|
# Push git commits and tags
|
||||||
git push && git push --tags
|
git push && git push --tags
|
||||||
|
|||||||
+5
-5
@@ -44,16 +44,16 @@ program
|
|||||||
.command("close")
|
.command("close")
|
||||||
.description("Close a multi-model tmux session")
|
.description("Close a multi-model tmux session")
|
||||||
.argument("<session-name>", "Name of the tmux session to close")
|
.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("-t, --add-tags", "Create archive tags before deleting branches", true)
|
.option("--no-tags", "Create archive tags before deleting branches")
|
||||||
.option("-f, --force", "Skip confirmation prompts", false)
|
.option("-f, --force", "Skip confirmation prompts", false)
|
||||||
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean; addTags: boolean }) => {
|
.action(async (sessionName: string, options: { keepWorktrees: boolean; force: boolean; tags: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const result = await closeMultiModel({
|
const result = await closeMultiModel({
|
||||||
sessionName,
|
sessionName,
|
||||||
cleanupWorktrees: options.cleanupWorktrees,
|
cleanupWorktrees: !options.keepWorktrees,
|
||||||
force: options.force,
|
force: options.force,
|
||||||
createArchiveTags: options.addTags,
|
createArchiveTags: options.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
+6
-6
@@ -54,14 +54,14 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
|||||||
const safeSessionName = sanitizeName(options.sessionName);
|
const safeSessionName = sanitizeName(options.sessionName);
|
||||||
try {
|
try {
|
||||||
// Check if session exists
|
// 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
|
// Kill tmux session if it exists
|
||||||
if (sessionExists) {
|
if (sessionExists) {
|
||||||
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
|
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||||
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
|
instructionsArr.push(chalk.green(`Closed session '${options.sessionName}'`));
|
||||||
} else {
|
} 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
|
// 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 cleanup requested and not forced, ask for confirmation
|
||||||
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
|
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) => {
|
worktrees.forEach((wt) => {
|
||||||
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
|
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): "));
|
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
|
||||||
|
|||||||
@@ -221,6 +221,23 @@ describe("closeMultiModel", () => {
|
|||||||
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`);
|
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 () => {
|
test("skip archive tags when flag disabled", async () => {
|
||||||
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
|
||||||
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model";
|
||||||
|
|||||||
@@ -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);
|
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 () => {
|
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["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: "" };
|
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