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 | ||
|
|
fc7feb2095 | ||
|
|
7180569b87 |
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.5",
|
"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
-3
@@ -44,14 +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("--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 }) => {
|
.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.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
+18
-16
@@ -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): "));
|
||||||
@@ -96,17 +96,19 @@ export async function closeMultiModel(options: CloseSessionOptions): Promise<Clo
|
|||||||
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
|
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
|
||||||
worktreesRemoved.push(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
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
if (options.createArchiveTags !== false) {
|
||||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||||
|
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
||||||
|
|
||||||
if (tagResult.ok) {
|
if (tagResult.ok) {
|
||||||
tagsCreated.push(archiveTag);
|
tagsCreated.push(archiveTag);
|
||||||
} else {
|
} 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);
|
instructionsArr.push(warningMsg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||||
branchesDeleted.push(worktree.branch);
|
branchesDeleted.push(worktree.branch);
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ export const closeTool = tool({
|
|||||||
args: {
|
args: {
|
||||||
sessionName: tool.schema.string().min(1).describe("tmux session name to close"),
|
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)"),
|
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) {
|
async execute(args, context) {
|
||||||
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
|
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
|
||||||
const result = await closeMultiModel({
|
const result = await closeMultiModel({
|
||||||
sessionName: args.sessionName,
|
sessionName: args.sessionName,
|
||||||
cleanupWorktrees: args.cleanupWorktrees,
|
cleanupWorktrees: args.cleanupWorktrees,
|
||||||
|
createArchiveTags: args.createArchiveTags,
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface MultiModelResult {
|
|||||||
* Options for closing a multi-model tmux session.
|
* Options for closing a multi-model tmux session.
|
||||||
*/
|
*/
|
||||||
export interface CloseSessionOptions {
|
export interface CloseSessionOptions {
|
||||||
|
/** Whether to create archive tags before deleting branches. */
|
||||||
|
createArchiveTags?: boolean;
|
||||||
/** Name of the tmux session to close. */
|
/** Name of the tmux session to close. */
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
/** Whether to remove worktrees and delete branches. */
|
/** 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 worktree remove -f ${worktreePath}`);
|
||||||
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 () => {
|
||||||
|
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);
|
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