Compare commits

..
Author SHA1 Message Date
bendtherules fc7feb2095 chore(release): 0.0.6 2026-03-21 01:28:15 +05:30
bendtherules 7180569b87 feat: add option to disable archive tags 2026-03-21 01:27:43 +05:30
bendtherules f0cc07f513 chore(release): 0.0.5 2026-03-21 01:11:36 +05:30
bendtherules 6dd4ee4b4f fix: correct path for cli.js in package.json 2026-03-21 01:11:30 +05:30
bendtherules dd7ac4dc2d chore(release): 0.0.4 2026-03-21 01:10:27 +05:30
bendtherules e8dbcff73e fix test 2026-03-21 01:10:21 +05:30
bendtherules 787fa75df4 build: color-code open and close outputs 2026-03-21 01:05:17 +05:30
bendtherules 17bb1130c8 build: color-code open output 2026-03-21 00:22:31 +05:30
bendtherules c642148cac test: add user confirmation handling for cleanup in closeMultiModel 2026-03-20 14:56:29 +05:30
bendtherules 7abfd6bf64 test: fix tests 2026-03-20 14:46:00 +05:30
bendtherules 93478294aa test: add error handling tests for closeMultiModel and openMultiModel 2026-03-20 14:20:34 +05:30
bendtherules 5d50038845 docs: update test coverage command in README 2026-03-20 14:08:00 +05:30
bendtherules 0b88ea0763 docs: add local code coverage instructions 2026-03-20 14:02:41 +05:30
bendtherules ffdc5451ab refactor: tests and add close.test.ts 2026-03-20 13:51:28 +05:30
bendtherules 8a087275b3 refactor: rename launchMultiModel to openMultiModel 2026-03-20 13:38:46 +05:30
bendtherules 962c9a474f refactor: rename src/core/launch.ts to open.ts 2026-03-20 13:33:30 +05:30
bendtherules 71523f2144 feat: add launch configuration for multi-model sessions in VSCode 2026-03-20 13:29:36 +05:30
bendtherules 1c2f203a34 feat: add initial VSCode settings for Bun configuration 2026-03-20 13:22:00 +05:30
bendtherules 536e30c5d0 test: add session name sanitization when fetching worktrees 2026-03-20 13:21:51 +05:30
bendtherules da0ed90192 fix: sanitize session name in getWorktreesForSession function 2026-03-20 13:17:10 +05:30
bendtherules 49da7c4219 chore(release): 0.0.3 2026-03-20 10:42:09 +05:30
bendtherules 41757e712f fix: update package name to remove username prefix 2026-03-20 10:41:58 +05:30
bendtherules 96e3922c42 chore(release): 0.0.2 2026-03-20 10:39:56 +05:30
bendtherules d6a2811ec4 fix: update release script permissions to executable 2026-03-20 10:39:48 +05:30
bendtherules c179491aed build: add release script 2026-03-20 10:38:07 +05:30
bendtherules 1b8f54d955 fix: revert version number to 0.0.1 in package.json 2026-03-20 10:35:36 +05:30
bendtherules 7cecbede6d fix: update npm @username to @bendtherules 2026-03-20 10:33:39 +05:30
bendtherules 05dc93b1ed fix: update model references from gpt-4o to gpt-5-2 in utils and tests 2026-03-20 10:23:55 +05:30
bendtherules d03e14178b fix: update model references from gpt-4o to gpt-5.2 in README and tests 2026-03-20 10:17:11 +05:30
bendtherules 5a56ed421d Add more tests 2026-03-20 10:15:25 +05:30
bendtherules 7e5eab32a6 delete: remove old multi-model test file 2026-03-20 10:10:39 +05:30
bendtherules d34401bc0e fix: streamline tmux session termination and cleanup logic 2026-03-20 08:34:38 +05:30
bendtherules 29e32ca0f1 build: Fix branch name delete 2026-03-19 20:07:17 +05:30
bendtherules bfd55e0f9f build: add direct cleanup command 2026-03-19 19:41:25 +05:30
bendtherules 5593bc8cc9 build: Fix tool name as local tool 2026-03-19 19:28:06 +05:30
bendtherules 61e8d1491d build: remove tsc 2026-03-19 19:20:39 +05:30
bendtherules ca753cd1ca Impl: tool-npm-package 2026-03-19 19:12:50 +05:30
25 changed files with 2733 additions and 1026 deletions
-527
View File
@@ -1,527 +0,0 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import multiModelTool, { __testing_helpers } from "./tools/multi-model";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model tool", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
"opencode models": { ok: true, stdout: "openai/gpt-4o\nanthropic/claude-3-5-sonnet", stderr: "" },
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await multiModelTool.execute(
{ sessionName: "", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: [] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `multi-model` requires a git repository.");
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `tmux` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux"
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `opencode` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode"
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["invalid/model"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Model name 'invalid/model' not found");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models"
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-4o"))).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test session!", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test session!` to join session");
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-4o"))).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-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o",
"git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"tmux new-session -d -s test-session -n gpt-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"git branch -D opencode/test-session/gpt-4o"
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-4o");
});
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Branch 'opencode/test-session/gpt-4o' already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "git error" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o",
"git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-4o\nanthropic/gpt-4o", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o-2"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "anthropic/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-4o-2"))).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: [longModel] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
describe("__testing_helpers", () => {
describe("shellQuote", () => {
test("normal string without special characters", () => {
expect(__testing_helpers.shellQuote("test")).toBe("'test'");
});
test("string with spaces", () => {
expect(__testing_helpers.shellQuote("test value")).toBe("'test value'");
});
test("string with single quotes", () => {
expect(__testing_helpers.shellQuote("test'value")).toBe("'test'\"'\"'value'");
});
test("string with multiple single quotes", () => {
expect(__testing_helpers.shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'");
});
test("empty string", () => {
expect(__testing_helpers.shellQuote("")).toBe("''");
});
});
describe("normalizeModels", () => {
test("empty array", () => {
expect(__testing_helpers.normalizeModels([])).toEqual([]);
});
test("undefined input", () => {
expect(__testing_helpers.normalizeModels(undefined)).toEqual([]);
});
test("array with whitespace-only strings", () => {
expect(__testing_helpers.normalizeModels([" ", " "])).toEqual([]);
});
test("array with mixed valid models, padded models, and empty strings", () => {
expect(__testing_helpers.normalizeModels([" openai/gpt-4o ", "", " anthropic/claude "])).toEqual(["openai/gpt-4o", "anthropic/claude"]);
});
});
describe("findDuplicates", () => {
test("array with no duplicates", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "c"])).toEqual([]);
});
test("array with one duplicate pair", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "a"])).toEqual(["a"]);
});
test("array with multiple different duplicates", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
});
test("array with a single value repeated more than twice", () => {
expect(__testing_helpers.findDuplicates(["a", "a", "a"])).toEqual(["a"]);
});
test("verifying first-repeated-occurrence order", () => {
expect(__testing_helpers.findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]);
});
});
describe("createWindowBaseName", () => {
test("normal model name without slashes", () => {
expect(__testing_helpers.createWindowBaseName("gpt-4o")).toBe("gpt-4o");
});
test("model with slashes", () => {
expect(__testing_helpers.createWindowBaseName("vendor/namespace/model")).toBe("model");
});
test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30);
expect(__testing_helpers.createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
});
test("model with special characters", () => {
expect(__testing_helpers.createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
});
test("model made entirely of special characters", () => {
expect(__testing_helpers.createWindowBaseName("!@#$%^&*()")).toBe("model");
});
});
describe("createWindowPlans", () => {
test("single model", () => {
expect(__testing_helpers.createWindowPlans(["openai/gpt-4o"])).toEqual([{ model: "openai/gpt-4o", windowName: "gpt-4o" }]);
});
test("two models with the same base name", () => {
expect(__testing_helpers.createWindowPlans(["openai/gpt-4o", "anthropic/gpt-4o"])).toEqual([
{ model: "openai/gpt-4o", windowName: "gpt-4o" },
{ model: "anthropic/gpt-4o", windowName: "gpt-4o-2" }
]);
});
test("multiple collisions", () => {
const result = __testing_helpers.createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3");
});
test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23);
const result = __testing_helpers.createWindowPlans([longBase, "b"]);
expect(result[1]?.windowName.length).toBeLessThanOrEqual(24);
});
});
describe("levenshtein", () => {
test("identical strings", () => {
expect(__testing_helpers.levenshtein("test", "test")).toBe(0);
});
test("one substitution", () => {
expect(__testing_helpers.levenshtein("test", "tent")).toBe(1);
});
test("one insertion", () => {
expect(__testing_helpers.levenshtein("test", "tests")).toBe(1);
});
test("one deletion", () => {
expect(__testing_helpers.levenshtein("tests", "test")).toBe(1);
});
test("completely different strings", () => {
expect(__testing_helpers.levenshtein("abc", "xyz")).toBe(3);
});
test("one empty string", () => {
expect(__testing_helpers.levenshtein("", "test")).toBe(4);
});
test("both empty strings", () => {
expect(__testing_helpers.levenshtein("", "")).toBe(0);
});
});
describe("suggestModels", () => {
test("exact match", () => {
const result = __testing_helpers.suggestModels("gpt-4o", ["gpt-4o", "gpt-4o-mini"]);
expect(result[0]).toBe("gpt-4o");
});
test("fuzzy match/typo", () => {
expect(__testing_helpers.suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
});
test("fuzzy match with contains boost", () => {
const suggestions = __testing_helpers.suggestModels("gpt4o", ["gpt-4o", "gpt-4o-mini"]);
expect(suggestions[0]).toBe("gpt-4o");
});
test("maximum of 3 suggestions", () => {
const allowlist = ["gpt-4o", "gpt-4o-mini", "gpt-4o-pro", "gpt-4o-ultra"];
expect(__testing_helpers.suggestModels("gpt4o", allowlist).length).toBe(3);
});
test("sorting logic", () => {
const suggestions = __testing_helpers.suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["abc", "abd", "abe"]);
});
});
describe("formatInvalidModelError", () => {
test("single invalid model with available suggestions", () => {
const error = __testing_helpers.formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-4o"]);
expect(error).toContain("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean");
});
test("single invalid model without close matches", () => {
const error = __testing_helpers.formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean");
});
test("multiple invalid models", () => {
const error = __testing_helpers.formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found");
expect(error).toContain("'xyz'");
expect(error).toContain("'abc'");
});
});
describe("sanitizeName", () => {
test("normal alphanumeric name", () => {
expect(__testing_helpers.sanitizeName("test-session")).toBe("test-session");
});
test("name with spaces and special chars", () => {
expect(__testing_helpers.sanitizeName("test session!")).toBe("test-session");
});
test("name with consecutive hyphens", () => {
expect(__testing_helpers.sanitizeName("test--session")).toBe("test-session");
});
test("name with leading/trailing hyphens", () => {
expect(__testing_helpers.sanitizeName("-test-session-")).toBe("test-session");
});
});
});
+5 -499
View File
@@ -1,500 +1,6 @@
import { tool } from "@opencode-ai/plugin"; // Re-export open and close tools for project-level use via relative paths.
import * as os from "node:os"; // For published package usage, change imports to "@username/opencode-multi-model".
import * as path from "node:path"; import { openTool } from "../../src/tools/open";
import * as fs from "node:fs"; import { closeTool } from "../../src/tools/close";
type WindowLaunchPlan = { export { openTool as open, closeTool as close };
model: string;
windowName: string;
};
type CommandResult = {
ok: boolean;
stdout: string;
stderr: string;
};
const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24;
/**
* Runs a command and captures its output without throwing on non-zero exit codes.
*
* @param parts Command segments to pass to the shell.
* @returns The exit status plus captured stdout and stderr.
*
* @example
* ```ts
* const result = await runCommand(["command", "-v", "tmux"]);
* if (!result.ok) {
* return "Error: `tmux` is not installed.";
* }
* ```
*/
async function runCommand(parts: string[]): Promise<CommandResult> {
const result = await Bun.$`${parts}`.quiet().nothrow();
return {
ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
/**
* Escapes a value for safe use inside a shell command string.
*
* @param value Raw user-provided value.
* @returns A POSIX-safe single-quoted string.
*
* @example
* ```ts
* const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`;
* ```
*/
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* Normalizes requested model ids by trimming whitespace and dropping empty items.
*
* @param models Raw tool input.
* @returns Clean model ids in the original order.
*
* @example
* ```ts
* const normalized = normalizeModels([" openai/gpt-5.4 ", ""]);
* // ["openai/gpt-5.4"]
* ```
*/
function normalizeModels(models: string[] | undefined): string[] {
return (models ?? []).map((model) => model.trim()).filter(Boolean);
}
/**
* Finds duplicate values while preserving their first repeated occurrence order.
*
* @param values Values to inspect.
* @returns Duplicate entries exactly once each.
*
* @example
* ```ts
* const duplicates = findDuplicates(["a", "b", "a", "b"]);
* // ["a", "b"]
* ```
*/
function findDuplicates(values: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
continue;
}
seen.add(value);
}
return [...duplicates];
}
/**
* Builds a short tmux-safe window label from a model id.
*
* @param model Full model id.
* @returns A concise window label.
*
* @example
* ```ts
* const label = createWindowBaseName("openai/gpt-5.4");
* // "gpt-5-4"
* ```
*/
function createWindowBaseName(model: string): string {
const preferredPart = model.split("/").at(-1) ?? model;
const sanitized = preferredPart
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, WINDOW_NAME_LIMIT);
return sanitized || "model";
}
/**
* Makes window names unique when sanitized model labels collide.
*
* @param models Validated model ids.
* @returns Window plans containing the full model id and unique tmux window name.
*
* @example
* ```ts
* const plans = createWindowPlans(["provider/a", "other/a"]);
* // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }]
* ```
*/
function createWindowPlans(models: string[]): WindowLaunchPlan[] {
const counts = new Map<string, number>();
return models.map((model) => {
const baseName = createWindowBaseName(model);
const nextCount = (counts.get(baseName) ?? 0) + 1;
counts.set(baseName, nextCount);
if (nextCount === 1) {
return { model, windowName: baseName };
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
return {
model,
windowName: `${trimmedBase}${suffix}`,
};
});
}
/**
* Computes Levenshtein distance for fuzzy model suggestions.
*
* @param left First string.
* @param right Second string.
* @returns Edit distance between the two strings.
*
* @example
* ```ts
* const distance = levenshtein("gpt5.4", "gpt-5.4");
* // 1
* ```
*/
function levenshtein(left: string, right: string): number {
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
let previous = row[0];
row[0] = leftIndex;
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const current = row[rightIndex];
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
row[rightIndex] = Math.min(
row[rightIndex]! + 1,
row[rightIndex - 1]! + 1,
previous! + cost,
);
previous = current;
}
}
return row[right.length]!;
}
/**
* Suggests close model ids for invalid input.
*
* @param requested Invalid requested model id.
* @param allowlist Known valid model ids.
* @returns Up to three likely matches ordered by relevance.
*
* @example
* ```ts
* const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]);
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
function suggestModels(requested: string, allowlist: string[]): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
.map((candidate) => {
const normalizedCandidate = candidate.toLowerCase();
const distance = levenshtein(normalizedRequested, normalizedCandidate);
const containsBoost =
normalizedCandidate.includes(normalizedRequested) ||
normalizedRequested.includes(normalizedCandidate)
? -2
: 0;
return {
candidate,
score: distance + containsBoost,
};
})
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
.slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate);
}
/**
* Formats invalid model errors with repair hints.
*
* @param invalidModels Invalid requested model ids.
* @param allowlist Known valid model ids.
* @returns A user-facing error string.
*
* @example
* ```ts
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
* ```
*/
function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string {
const firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText =
suggestions.length > 0
? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?`
: " Run `opencode models` and try again.";
if (invalidModels.length === 1) {
return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`;
}
return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`;
}
/**
* Launches an OpenCode command in a tmux window.
*
* @param sessionName Existing tmux session name.
* @param plan Window launch plan.
* @returns Result describing whether the command was sent successfully.
*
* @example
* ```ts
* await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" });
* ```
*/
async function launchModelInWindow(sessionName: string, plan: WindowLaunchPlan): Promise<CommandResult> {
const launchCommand = `opencode --model ${shellQuote(plan.model)}`;
// Send the exact command text into the pane so tmux keeps the user's normal shell setup.
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({
description: "Launch multiple OpenCode models in tmux",
args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to create"),
models: tool.schema
.array(tool.schema.string().min(1))
.min(1)
.describe("one or more OpenCode model ids to launch"),
},
async execute(args, context) {
const sessionName = args.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(args.models);
if (!sessionName) {
return "Error: `sessionName` must not be empty.";
}
if (models.length === 0) {
return "Error: Model names must not be empty.";
}
const duplicateModels = findDuplicates(models);
if (duplicateModels.length > 0) {
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"]);
if (!tmuxExists.ok) {
return "Error: `tmux` is not installed or not on `PATH`.";
}
const opencodeExists = await runCommand(["command", "-v", "opencode"]);
if (!opencodeExists.ok) {
return "Error: `opencode` is not installed or not on `PATH`.";
}
const modelListResult = await runCommand(["opencode", "models"]);
if (!modelListResult.ok) {
return `Error: Failed to load valid models from \`opencode models\`${modelListResult.stderr ? `: ${modelListResult.stderr}` : "."}`;
}
const allowlist = modelListResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) {
return formatInvalidModelError(invalidModels, allowlist);
}
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
if (sessionExists.ok) {
return "Error: Session already exists. Use a different `sessionName`.";
}
const windowPlans = createWindowPlans(models);
const succeededModels: string[] = [];
const failedModels: string[] = [];
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([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!sessionCreateResult.ok) {
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;
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!windowCreateResult.ok) {
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;
}
}
const launchResult = await launchModelInWindow(sessionName, plan);
if (launchResult.ok) {
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 cleanupCommand = `tmux kill-session -t ${sessionName} && rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
context.metadata({
title: `multi-model ${sessionName}`,
metadata: {
safeSessionName,
modelCount: models.length,
},
});
if (failedModels.length > 0) {
return [
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
`Failed: ${failedModels.join(", ")}.`,
`Attach with \`${attachCommand}\` to inspect the session.`,
`Cleanup with \n\`${cleanupCommand}\`\nwhen done.`
].join("\n");
}
return [
`Use \`${attachCommand}\` to join session.`,
`When finished, clean up worktrees with:`,
`\`${cleanupCommand}\``
].join("\n");
},
});
export const __testing_helpers = {
shellQuote,
normalizeModels,
findDuplicates,
createWindowBaseName,
createWindowPlans,
levenshtein,
suggestModels,
formatInvalidModelError,
sanitizeName,
runCommand,
getWorktreePath,
undoWorktree,
launchModelInWindow,
};
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Open Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["open", "test/session1", "--models", "openai/gpt-5.2"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Close Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["close", "test/session1"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
// The path to the `bun` executable.
"bun.runtime": "/path/to/bun",
// If support for Bun should be added to the default "JavaScript Debug Terminal".
"bun.debugTerminal.enabled": true,
// If the debugger should stop on the first line of the program.
"bun.debugTerminal.stopOnEntry": false,
// Glob pattern to find test files. Defaults to the value shown below.
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}",
// The custom script to call for testing instead of `bun test`
"bun.test.customScript": "bun test",
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+184
View File
@@ -0,0 +1,184 @@
# opencode-multi-model
Launch multiple AI models in tmux sessions. Available as:
- OpenCode Plugin
- Standalone CLI
- Project-level tool
## Installation
### As OpenCode Plugin
Add to your OpenCode config (`opencode.json`):
```json
{
"plugin": ["opencode-multi-model"]
}
```
### As Standalone CLI
```bash
# Using bunx (no install)
bunx opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
# Or install globally
bun install -g opencode-multi-model
opencode-multi-model open my-session -m openai/gpt-5.2
```
### As Project Tool
If you installed it via bun and want to use it as a project tool, import it from the package:
```typescript
// .opencode/tools/multi-model.ts
import { openTool as open, closeTool as close } from "opencode-multi-model"
export { open, close }
```
If you are developing this package locally and want to test it in a project without publishing, import via relative paths:
```typescript
// .opencode/tools/multi-model.ts
import { openTool } from "../../src/tools/open"
import { closeTool } from "../../src/tools/close"
export { openTool as open, closeTool as close }
```
## Usage
### Plugin Usage
**Open a session:**
```
Use multi-model-open tool:
sessionName: my-session, models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"]
```
**Close a session (using tool):**
```
call tool multi-model-close with sessionName: my-session
```
**Close a session (using manual command):**
```bash
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
```
### CLI Usage
**Open a session:**
```bash
# Basic usage
opencode-multi-model open my-session -m openai/gpt-5.2 anthropic/claude-3-5-sonnet
# With custom binary (via flag)
opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
# With custom binary (via env)
export OPENCODE_MULTI_MODEL_BINARY=kilo
opencode-multi-model open my-session -m openai/gpt-5.2
```
**Close a session (CLI command):**
```bash
# Close and cleanup worktrees
opencode-multi-model close my-session
```
**Close a session (manual command):**
```bash
tmux kill-session -t my-session && rm -rf ~/.local/share/opencode/multi-model/my-session && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/my-session/*')
```
**Help:**
```bash
opencode-multi-model --help
opencode-multi-model open --help
opencode-multi-model close --help
```
## Configuration
### Custom Binary
By default uses `opencode`. To use `kilo` instead:
**Via environment variable:**
```bash
export OPENCODE_MULTI_MODEL_BINARY=kilo
```
**Via CLI flag:**
```bash
opencode-multi-model open my-session -m openai/gpt-5.2 -b kilo
```
Priority: CLI flag > Environment variable > Default ("opencode")
## Requirements
- tmux
- git
- opencode or kilo binary
- Bun runtime
## Development
For local development and testing without publishing:
```bash
# Install dependencies
bun install
# Build
bun run build
# Run tests
bun test
# Test CLI locally
bun dist/cli.js open test-session -m openai/gpt-5.2
bun dist/cli.js close test-session
```
## Architecture
```
├── src/
│ ├── index.ts # Plugin entry point
│ ├── cli.ts # CLI entry point
│ ├── types.ts # Shared TypeScript types
│ ├── core/
│ │ ├── index.ts # Core exports
│ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ └── utils.ts # Helper functions
│ └── tools/
│ ├── open.ts # Open tool definition
│ └── close.ts # Close tool definition
├── tests/
│ └── multi-model.test.ts # Tests
├── package.json
├── tsconfig.json
└── README.md
```
## Local code coverage
The test suite can generate a coverage report locally.
```bash
# Run tests with coverage (HTML report generated in ./coverage)
npm run coverage # or: bun run coverage
# Open the HTML report
open coverage/index.html # macOS
```
The `coverage` directory is ignored by Git but can be inspected to see which lines are exercised.
## License
MIT
+37
View File
@@ -0,0 +1,37 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@username/opencode-multi-model",
"dependencies": {
"@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0",
"commander": "^12.0.0",
},
"devDependencies": {
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0",
},
},
},
"packages": {
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "opencode-multi-model",
"version": "0.0.6",
"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"
},
"exports": {
".": "./dist/index.js",
"./core": "./dist/core/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
"test": "bun test",
"coverage": "bun test --coverage",
"prepublishOnly": "bun run build",
"release": "bash ./scripts/release.sh"
},
"dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0"
},
"devDependencies": {
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0"
},
"keywords": [
"opencode",
"multi-model",
"tmux",
"ai",
"plugin"
],
"license": "MIT"
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -e
# Prompt for version bump type
read -p "Select version bump (major/minor/patch): " bump
if [[ ! "$bump" =~ ^(major|minor|patch)$ ]]; then
echo "Invalid choice. Must be 'major', 'minor', or 'patch'."
exit 1
fi
# Run tests
echo "Running test suite..."
npm test
# Build the project
echo "Building the project..."
npm run build
# Bump version
echo "Bumping version ($bump)..."
npm version "$bump" -m "chore(release): %s"
# Show git status
git status
# Confirm publishing
read -p "Proceed with npm publish? (y/N): " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Publish aborted."
exit 0
fi
# Publish to npm
npm publish
# Push git commits and tags
git push && git push --tags
echo "Release completed successfully."
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { openMultiModel } from "./core/open";
import { closeMultiModel } from "./core/close";
import { getBinaryName } from "./core/utils";
const program = new Command();
program
.name("opencode-multi-model")
.description("Launch multiple OpenCode models in tmux sessions")
.version("1.0.0");
program
.command("open")
.description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session")
.option("-m, --models <models...>", "Model IDs to launch (space-separated)", [])
.option("-b, --binary <binary>", "Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'")
.action(async (sessionName: string, options: { models: string[]; binary?: string }) => {
try {
const binaryName = options.binary || getBinaryName();
const result = await openMultiModel({
sessionName,
models: options.models,
binaryName,
mode: "cli",
});
if (result.success) {
console.log(result.instructions || `Created session: ${result.sessionName}`);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
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("-t, --add-tags", "Create archive tags before deleting branches", true)
.option("-f, --force", "Skip confirmation prompts", false)
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean; addTags: boolean }) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: options.cleanupWorktrees,
force: options.force,
createArchiveTags: options.addTags,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
program.parse();
+157
View File
@@ -0,0 +1,157 @@
import * as readline from "node:readline";
import chalk from 'chalk'; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import { runCommand, getSessionPath, getWorktreesForSession, sanitizeName } from "./utils";
/**
* Prompts the user for input on the terminal.
*
* @param question The question to display.
* @returns The user's answer.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* Closes a multi-model tmux session and optionally cleans up worktrees and branches.
*
* When `cleanupWorktrees` is true, removes git worktrees, creates archive tags
* for recovery, and deletes the associated branches.
*
* @param options Close configuration including session name and cleanup flags.
* @returns Result with details about what was cleaned up.
*
* @example
* ```ts
* const result = await closeMultiModel({
* sessionName: "compare",
* cleanupWorktrees: true,
* force: true,
* });
* if (result.success) {
* console.log(`Closed ${result.sessionName}, removed ${result.worktreesRemoved?.length} worktrees`);
* }
* ```
*/
export async function closeMultiModel(options: CloseSessionOptions): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const instructionsArr: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const safeSessionName = sanitizeName(options.sessionName);
try {
// Check if session exists
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", safeSessionName]);
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", safeSessionName]);
instructionsArr.push(chalk.green(`Closed session '${safeSessionName}'`));
} else {
instructionsArr.push(chalk.yellow(`Session '${safeSessionName}' does not exist`));
}
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(safeSessionName);
// 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:'));
worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
instructionsArr.push(msg.trim());
});
const answer = await promptUser(chalk.bold("\nDo you want to proceed? (y/N): "));
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
sessionName: safeSessionName,
error: "Cleanup cancelled by user",
instructions: instructionsArr.join("\n"),
};
}
}
let cleanupPerformed = false;
// Cleanup worktrees and optionally branches
if (options.cleanupWorktrees) {
for (const worktree of worktrees) {
try {
// Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path);
// 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);
}
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
instructionsArr.push(chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`));
}
}
// Also remove the base directory
const worktreeBase = getSessionPath(safeSessionName);
try {
await runCommand(["rm", "-rf", worktreeBase]);
} catch {
// Ignore errors
}
cleanupPerformed = worktreesRemoved.length > 0;
if (cleanupPerformed) {
instructionsArr.push(chalk.dim(`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`));
if (tagsCreated.length > 0) {
instructionsArr.push(chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`));
}
}
}
return {
success: true,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
} catch (error) {
return {
success: false,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export { openMultiModel } from "./open";
export { closeMultiModel } from "./close";
export * from "./utils";
+238
View File
@@ -0,0 +1,238 @@
import * as fs from "node:fs";
import chalk from 'chalk';
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
runCommand,
normalizeModels,
findDuplicates,
createWindowPlans,
formatInvalidModelError,
sanitizeName,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
} from "./utils";
/**
* Launches multiple AI models in a tmux session with git worktrees.
*
* Creates a tmux session with one window per model, each in its own git worktree.
* Validates models against `opencode models` output before launching.
*
* @param options Launch configuration including session name and model list.
* @returns Result with success status, window names, and usage instructions.
*
* @example
* ```ts
* const result = await openMultiModel({
* sessionName: "compare",
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode",
* });
* if (result.success) {
* console.log(result.instructions);
* }
* ```
*/
export async function openMultiModel(options: MultiModelOptions): Promise<MultiModelResult> {
const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models);
const binaryName = options.binaryName || "opencode";
const mode = options.mode || "cli";
if (!sessionName) {
return { success: false, sessionName: "", error: "Error: `sessionName` must not be empty." };
}
if (models.length === 0) {
return { success: false, sessionName, error: "Error: Model names must not be empty." };
}
const duplicateModels = findDuplicates(models);
if (duplicateModels.length > 0) {
return {
success: false,
sessionName,
error: `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 { success: false, sessionName, error: "Error: `multi-model` requires a git repository." };
}
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) {
return { success: false, sessionName, error: "Error: `tmux` is not installed or not on `PATH`." };
}
const binaryExists = await runCommand(["command", "-v", binaryName]);
if (!binaryExists.ok) {
return { success: false, sessionName, error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.` };
}
const modelListResult = await runCommand([binaryName, "models"]);
if (!modelListResult.ok) {
return {
success: false,
sessionName,
error: `Error: Failed to load valid models from \`${binaryName} models\`${modelListResult.stderr ? `: ${modelListResult.stderr}` : "."}`,
};
}
const allowlist = modelListResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) {
return { success: false, sessionName, error: formatInvalidModelError(invalidModels, allowlist) };
}
const sessionExists = await runCommand(["tmux", "has-session", "-t", sessionName]);
if (sessionExists.ok) {
return { success: false, sessionName, error: "Error: Session already exists. Use a different `sessionName`." };
}
const windowPlans = createWindowPlans(models);
const succeededModels: string[] = [];
const failedModels: string[] = [];
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 { success: false, sessionName, error: `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 { success: false, sessionName, error: `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 { success: false, sessionName, error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}` };
}
}
if (isFirst) {
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!sessionCreateResult.ok) {
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 { success: false, sessionName, error: errorMsg };
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!windowCreateResult.ok) {
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;
}
}
const launchResult = await launchModelInWindow(sessionName, plan, binaryName);
if (launchResult.ok) {
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 windowNames = windowPlans.map((p) => p.windowName);
const attachCommand = `tmux attach -t ${sessionName}`;
const cleanupCommand = `tmux kill-session -t ${sessionName} && rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
const primaryCleanup =
mode === "tool"
? `call tool multi-model-close with sessionName: ${sessionName}`
: `opencode-multi-model close ${sessionName}`;
if (failedModels.length > 0) {
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
chalk.red(`Error: Created tmux session '${sessionName}', but some model launches failed.`),
chalk.red(`Failed: ${failedModels.join(", ")}.`),
chalk.dim(`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`),
"",
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"),
};
}
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
chalk.bold(`Use \`${attachCommand}\` to join session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand)
].join("\n"),
};
}
+402
View File
@@ -0,0 +1,402 @@
import * as os from "node:os";
import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24;
/**
* Runs a command and captures its output without throwing on non-zero exit codes.
*
* @param parts Command segments to pass to the shell.
* @returns The exit status plus captured stdout and stderr.
*
* @example
* ```ts
* const result = await runCommand(["command", "-v", "tmux"]);
* if (!result.ok) {
* return "Error: `tmux` is not installed.";
* }
* ```
*/
export async function runCommand(parts: string[]): Promise<CommandResult> {
const result = await Bun.$`${parts}`.quiet().nothrow();
return {
ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
/**
* Escapes a value for safe use inside a shell command string.
*
* @param value Raw user-provided value.
* @returns A POSIX-safe single-quoted string.
*
* @example
* ```ts
* const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`;
* ```
*/
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* Normalizes requested model ids by trimming whitespace and dropping empty items.
*
* @param models Raw tool input.
* @returns Clean model ids in the original order.
*
* @example
* ```ts
* const normalized = normalizeModels([" openai/gpt-5.4 ", ""]);
* // ["openai/gpt-5.4"]
* ```
*/
export function normalizeModels(models: string[] | undefined): string[] {
return (models ?? []).map((model) => model.trim()).filter(Boolean);
}
/**
* Finds duplicate values while preserving their first repeated occurrence order.
*
* @param values Values to inspect.
* @returns Duplicate entries exactly once each.
*
* @example
* ```ts
* const duplicates = findDuplicates(["a", "b", "a", "b"]);
* // ["a", "b"]
* ```
*/
export function findDuplicates(values: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
continue;
}
seen.add(value);
}
return [...duplicates];
}
/**
* Builds a short tmux-safe window label from a model id.
*
* @param model Full model id.
* @returns A concise window label.
*
* @example
* ```ts
* const label = createWindowBaseName("openai/gpt-5.4");
* // "gpt-5-4"
* ```
*/
export function createWindowBaseName(model: string): string {
const preferredPart = model.split("/").at(-1) ?? model;
const sanitized = preferredPart
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, WINDOW_NAME_LIMIT);
return sanitized || "model";
}
/**
* Makes window names unique when sanitized model labels collide.
*
* @param models Validated model ids.
* @returns Window plans containing the full model id and unique tmux window name.
*
* @example
* ```ts
* const plans = createWindowPlans(["provider/a", "other/a"]);
* // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }]
* ```
*/
export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
const counts = new Map<string, number>();
return models.map((model) => {
const baseName = createWindowBaseName(model);
const nextCount = (counts.get(baseName) ?? 0) + 1;
counts.set(baseName, nextCount);
if (nextCount === 1) {
return { model, windowName: baseName };
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(0, Math.max(1, WINDOW_NAME_LIMIT - suffix.length));
return {
model,
windowName: `${trimmedBase}${suffix}`,
};
});
}
/**
* Computes Levenshtein distance for fuzzy model suggestions.
*
* @param left First string.
* @param right Second string.
* @returns Edit distance between the two strings.
*
* @example
* ```ts
* const distance = levenshtein("gpt5.4", "gpt-5.4");
* // 1
* ```
*/
export function levenshtein(left: string, right: string): number {
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
let previous = row[0];
row[0] = leftIndex;
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const current = row[rightIndex];
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
row[rightIndex] = Math.min(
row[rightIndex]! + 1,
row[rightIndex - 1]! + 1,
previous! + cost,
);
previous = current;
}
}
return row[right.length]!;
}
/**
* Suggests close model ids for invalid input.
*
* @param requested Invalid requested model id.
* @param allowlist Known valid model ids.
* @returns Up to three likely matches ordered by relevance.
*
* @example
* ```ts
* const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]);
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
export function suggestModels(requested: string, allowlist: string[]): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
.map((candidate) => {
const normalizedCandidate = candidate.toLowerCase();
const distance = levenshtein(normalizedRequested, normalizedCandidate);
const containsBoost =
normalizedCandidate.includes(normalizedRequested) ||
normalizedRequested.includes(normalizedCandidate)
? -2
: 0;
return {
candidate,
score: distance + containsBoost,
};
})
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
.slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate);
}
/**
* Formats invalid model errors with repair hints.
*
* @param invalidModels Invalid requested model ids.
* @param allowlist Known valid model ids.
* @returns A user-facing error string.
*
* @example
* ```ts
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
* ```
*/
export function formatInvalidModelError(invalidModels: string[], allowlist: string[]): string {
const firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText =
suggestions.length > 0
? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?`
: " Run `opencode models` and try again.";
if (invalidModels.length === 1) {
return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`;
}
return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`;
}
/**
* Launches an OpenCode command in a tmux window.
*
* @param sessionName Existing tmux session name.
* @param plan Window launch plan.
* @param binaryName Binary to launch ('opencode' or 'kilo').
* @returns Result describing whether the command was sent successfully.
*
* @example
* ```ts
* await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" });
* ```
*/
export async function launchModelInWindow(
sessionName: string,
plan: WindowLaunchPlan,
binaryName: string = "opencode",
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand(["tmux", "send-keys", "-t", `${sessionName}:${plan.windowName}`, launchCommand, "C-m"]);
}
/**
* Sanitizes a session name to be safe for paths and branch names.
*
* @param name Raw session name.
* @returns Sanitized name safe for use in paths and git branches.
*
* @example
* ```ts
* sanitizeName("test session!");
* // "test-session"
* ```
*/
export function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
/**
* Generates the full absolute path for a session's base directory.
*
* @param sessionName Sanitized session name.
* @returns Absolute path to the session directory.
*
* @example
* ```ts
* getSessionPath("my-session");
* // "/home/user/.local/share/opencode/multi-model/my-session"
* ```
*/
export function getSessionPath(sessionName: string): string {
const homedir = os.homedir();
return path.join(homedir, ".local", "share", "opencode", "multi-model", sessionName);
}
/**
* Generates the full absolute path for a worktree.
*
* @param safeSession Sanitized session name.
* @param windowName Tmux window name.
* @returns Absolute path to the worktree directory.
*
* @example
* ```ts
* getWorktreePath("my-session", "gpt-5-2");
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
* ```
*/
export function getWorktreePath(safeSession: string, windowName: string): string {
return path.join(getSessionPath(safeSession), windowName);
}
/**
* Retrieves all git worktrees associated with a session.
*
* @param sessionName Sanitized session name.
* @returns Array of worktree info objects filtered to the session.
*
* @example
* ```ts
* const worktrees = await getWorktreesForSession("my-session");
* ```
*/
export async function getWorktreesForSession(sessionName: string): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand(["git", "worktree", "list", "--porcelain"]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {};
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
currentWorktree = {
path: line.slice(9),
};
} else if (line.startsWith("branch ")) {
currentWorktree.branch = line.slice(7).replace(/^refs\/heads\//, "");
}
}
// Add last worktree
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
// Filter for session-specific worktrees
return worktrees.filter((wt) => wt.path.startsWith(sessionPath));
}
/**
* Removes a git worktree and deletes its branch on failure.
*
* @param worktreePath Absolute path to the worktree.
* @param branchName Branch name to delete.
* @returns Array of error messages (empty if successful).
*
* @example
* ```ts
* const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
* if (errors.length > 0) console.error(errors);
* ```
*/
export 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;
}
/**
* Determines the binary name to use based on environment variable or default.
*
* Priority: env var OPENCODE_MULTI_MODEL_BINARY > default ("opencode").
*
* @returns Binary name string.
*
* @example
* ```ts
* // With OPENCODE_MULTI_MODEL_BINARY=kilo
* getBinaryName(); // "kilo"
*
* // Without env var
* getBinaryName(); // "opencode"
* ```
*/
export function getBinaryName(): string {
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode";
}
+32
View File
@@ -0,0 +1,32 @@
import type { Plugin } from "@opencode-ai/plugin";
import { openTool } from "./tools/open";
import { closeTool } from "./tools/close";
/**
* OpenCode plugin that provides multi-model tmux session management tools.
*
* Registers two tools:
* - `multi-model-open`: Launch multiple AI models in a tmux session
* - `multi-model-close`: Close a multi-model session and cleanup resources
*
* @example
* ```json
* // opencode.json
* {
* "plugin": ["@username/opencode-multi-model"]
* }
* ```
*/
const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
return {
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
},
};
};
export default OpenCodeMultiModelPlugin;
export { openTool, closeTool };
export * from "./core/index";
export * from "./types";
+32
View File
@@ -0,0 +1,32 @@
import { tool } from "@opencode-ai/plugin";
import { closeMultiModel } from "../core/close";
/**
* OpenCode tool definition for closing multi-model tmux sessions.
*
* Kills the tmux session and optionally removes git worktrees,
* creates archive tags, and deletes associated branches.
*/
export const closeTool = tool({
description: "Close a multi-model tmux session and optionally cleanup worktrees and branches",
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,
});
if (!result.success) {
return result.error!;
}
return result.instructions;
},
});
+44
View File
@@ -0,0 +1,44 @@
import { tool } from "@opencode-ai/plugin";
import { openMultiModel } from "../core/open";
import { getBinaryName } from "../core/utils";
/**
* OpenCode tool definition for launching multiple AI models in tmux sessions.
*
* Creates a tmux session with one window per model, each in its own git worktree.
* Returns instructions for attaching to and cleaning up the session.
*/
export const openTool = tool({
description: "Launch multiple OpenCode models in tmux",
args: {
sessionName: tool.schema.string().min(1).describe("tmux session name to create"),
models: tool.schema
.array(tool.schema.string().min(1))
.min(1)
.describe("one or more OpenCode model ids to launch"),
},
async execute(args, context) {
const binaryName = getBinaryName();
const result = await openMultiModel({
sessionName: args.sessionName,
models: args.models,
binaryName,
mode: "tool",
});
context.metadata({
title: `multi-model ${args.sessionName}`,
metadata: {
safeSessionName: result.sessionName,
modelCount: args.models.length,
},
});
if (!result.success) {
return result.error!;
}
return result.instructions || `Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`;
},
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Options for launching a multi-model tmux session.
*/
export interface MultiModelOptions {
/** Name for the tmux session. */
sessionName: string;
/** List of model ids to launch. */
models: string[];
/** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */
binaryName?: string;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode?: "cli" | "tool";
}
/**
* Result returned after attempting to launch a multi-model session.
*/
export interface MultiModelResult {
/** Whether the launch succeeded. */
success: boolean;
/** The session name used. */
sessionName: string;
/** Window names created in the tmux session. */
windows?: string[];
/** Error message if the launch failed. */
error?: string;
/** Instructions for attaching and cleaning up the session. */
instructions?: string;
}
/**
* 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. */
cleanupWorktrees?: boolean;
/** Skip confirmation prompts. */
force?: boolean;
}
/**
* Result returned after attempting to close a multi-model session.
*/
export interface CloseSessionResult {
/** Console messages and instructions. */
instructions: string;
/** Whether the close succeeded. */
success: boolean;
/** The session name that was closed. */
sessionName: string;
/** Error message if the close failed. */
error?: string;
/** Whether cleanup was performed. */
cleanupPerformed?: boolean;
/** Worktree paths that were removed. */
worktreesRemoved?: string[];
/** Branch names that were deleted. */
branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */
tagsCreated?: string[];
}
/**
* Internal type for a window launch plan.
*/
export interface WindowLaunchPlan {
/** Full model id. */
model: string;
/** Unique tmux window name. */
windowName: string;
}
/**
* Result of running a shell command.
*/
export interface CommandResult {
/** Whether the command exited with code 0. */
ok: boolean;
/** Captured stdout. */
stdout: string;
/** Captured stderr. */
stderr: string;
}
/**
* Information about a git worktree.
*/
export interface WorktreeInfo {
/** Absolute path to the worktree. */
path: string;
/** Branch name associated with the worktree. */
branch: string;
}
+244
View File
@@ -0,0 +1,244 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as os from "node:os";
import * as readline from "node:readline";
import { closeMultiModel } from "../src/core/close";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel", () => {
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("kills existing session without cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
expect(executedCommands).toContain("tmux kill-session -t test-session");
});
test("no session exists, no cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" };
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false });
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
// No kill-session should be issued
expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false);
});
test("cleanup works with worktrees and force, all succeeds", 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: "" };
// Tag command will default to ok:true via fallback
mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
// @ts-ignore
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
global.Date = OriginalDate;
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).toBe(1);
// Ensure removal commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
});
test("user cancels cleanup when not forced", 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: "" };
// Stub readline to return "n"
const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
});
test("user confirms cleanup when not forced", 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: "" };
// Stub readline to simulate user confirming cleanup (answers "y")
const mockInterface = {
question: (q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
// @ts-ignore
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false });
// Restore Date after invocation
// @ts-ignore
global.Date = OriginalDate;
// Verify result indicates successful cleanup
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).toBe(1);
// Verify the expected git commands were executed
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
expect(executedCommands).toContain(`git branch -D opencode/test-session/model`);
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
expect(executedCommands).toContain(expectedTagCmd);
});
test("warning when tag creation fails", 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: "" };
// Tag command will fail
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
// @ts-ignore
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
mockCommandResponses[tagCommand] = { ok: false, stdout: "", stderr: "tag error" };
const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
global.Date = OriginalDate;
expect(result.success).toBe(true);
// Expect the warning message to be captured in instructions
expect(result.instructions.includes('Failed to create tag')).toBe(true);
expect(result.tagsCreated).toEqual([]);
});
test("cleanup works with unsanitized session name", async () => {
const unsanitized = "test session!";
const sanitized = "test-session"; // result of sanitizeName
// tmux lookup uses unsanitized name
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" };
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/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/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
// @ts-ignore
global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any;
const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true });
// Restore Date
// @ts-ignore
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
expect(result.tagsCreated?.length).toBe(1);
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`);
expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`);
});
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);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test";
import * as os from "node:os";
import { closeMultiModel } from "../src/core/close";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
// Simulate throwing error for a specific command
if (cmd === "git worktree list --porcelain") {
throw new Error("unexpected error");
}
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel unexpected error handling", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("catches thrown error and returns failure", async () => {
const result = await closeMultiModel({ sessionName: "any-session", cleanupWorktrees: false, force: false });
expect(result.success).toBe(false);
expect(result.error).toContain("unexpected error");
});
});
+357
View File
@@ -0,0 +1,357 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
import {
shellQuote,
normalizeModels,
findDuplicates,
createWindowBaseName,
createWindowPlans,
levenshtein,
suggestModels,
formatInvalidModelError,
sanitizeName,
runCommand,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
getWorktreesForSession,
getBinaryName,
} from "../src/core/utils";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model launch", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
"opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" },
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await openMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: [],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `multi-model` requires a git repository.");
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["invalid/model"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model name 'invalid/model' not found");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session");
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/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: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2",
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: [longModel],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session");
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
// ----------
// Core utils tests remain in a separate file.
+236
View File
@@ -0,0 +1,236 @@
import { expect, test, mock, beforeEach, afterEach, describe, spyOn } from "bun:test";
import * as os from "node:os";
import * as fs from "node:fs";
import { openMultiModel } from "../src/core/open";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
const response = mockCommandResponses[cmd] ?? { ok: true, stdout: "", stderr: "" };
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("openMultiModel error paths", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails when model list command fails", async () => {
const session = "sess";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: false, stdout: "", stderr: "model error" };
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to load valid models");
});
test("fails when duplicate models provided", async () => {
const session = "dup";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" };
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2", "openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
});
test("fails when session already exists", async () => {
const session = "existing";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2", stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models: ["openai/gpt-5.2"], binaryName: "opencode", mode: "cli" });
expect(result.success).toBe(false);
expect(result.error).toContain("Session already exists");
});
test("second model fails because branch already exists", async () => {
const session = "sess-add";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
// common mocks
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model branch exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree creation fails", async () => {
const session = "sess-wt";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model fails worktree
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: false, stdout: "", stderr: "wt error" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model window creation fails", async () => {
const session = "sess-win";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model window failure
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: false, stdout: "", stderr: "win err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session"); expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model launch command fails", async () => {
const session = "sess-launch";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model launch fails
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`] = { ok: false, stdout: "", stderr: "launch err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`git branch -D ${secondBranch}`] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (launch err");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree path already exists", async () => {
const session = "sess-wtpath";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: true, stdout: "true", stderr: "" };
mockCommandResponses["command -v tmux"] = { ok: true, stdout: "tmux", stderr: "" };
mockCommandResponses["command -v opencode"] = { ok: true, stdout: "opencode", stderr: "" };
mockCommandResponses["opencode models"] = { ok: true, stdout: models.join("\n"), stderr: "" };
mockCommandResponses[`tmux has-session -t ${session}`] = { ok: false, stdout: "", stderr: "" };
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${firstBranch}`] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`] = { ok: true, stdout: "", stderr: "" };
// second model path exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[`git show-ref --verify --quiet refs/heads/${secondBranch}`] = { ok: false, stdout: "", stderr: "" };
// mock fs existsSync to return true for secondPath
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
const result = await openMultiModel({ sessionName: session, models, binaryName: binary, mode: "cli" });
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Worktree path already exists at");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
});
+377
View File
@@ -0,0 +1,377 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import {
shellQuote,
normalizeModels,
findDuplicates,
createWindowBaseName,
createWindowPlans,
levenshtein,
suggestModels,
formatInvalidModelError,
sanitizeName,
runCommand,
getWorktreePath,
getSessionPath,
undoWorktree,
launchModelInWindow,
getWorktreesForSession,
getBinaryName,
} from "../src/core/utils";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("core utils", () => {
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
describe("shellQuote", () => {
test("normal string without special characters", () => {
expect(shellQuote("test")).toBe("'test'");
});
test("string with spaces", () => {
expect(shellQuote("test value")).toBe("'test value'");
});
test("string with single quotes", () => {
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
});
test("string with multiple single quotes", () => {
expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'");
});
test("empty string", () => {
expect(shellQuote("")).toBe("''");
});
});
describe("normalizeModels", () => {
test("empty array", () => {
expect(normalizeModels([])).toEqual([]);
});
test("undefined input", () => {
expect(normalizeModels(undefined)).toEqual([]);
});
test("array with whitespace-only strings", () => {
expect(normalizeModels([" ", " "])).toEqual([]);
});
test("array with mixed valid models, padded models, and empty strings", () => {
expect(normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
});
});
describe("findDuplicates", () => {
test("array with no duplicates", () => {
expect(findDuplicates(["a", "b", "c"])).toEqual([]);
});
test("array with one duplicate pair", () => {
expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]);
});
test("array with multiple different duplicates", () => {
expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
});
test("array with a single value repeated more than twice", () => {
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
});
test("verifying first-repeated-occurrence order", () => {
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]);
});
});
describe("createWindowBaseName", () => {
test("normal model name without slashes", () => {
expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2");
});
test("model with slashes", () => {
expect(createWindowBaseName("vendor/namespace/model")).toBe("model");
});
test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30);
expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
});
test("model with special characters", () => {
expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
});
test("model made entirely of special characters", () => {
expect(createWindowBaseName("!@#$%^&*()")).toBe("model");
});
});
describe("createWindowPlans", () => {
test("single model", () => {
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]);
});
test("two models with the same base name", () => {
expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
]);
});
test("multiple collisions", () => {
const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3");
});
test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23);
const result = createWindowPlans([longBase, "b"]);
expect(result[1]?.windowName.length).toBeLessThanOrEqual(24);
});
});
describe("levenshtein", () => {
test("identical strings", () => {
expect(levenshtein("test", "test")).toBe(0);
});
test("one substitution", () => {
expect(levenshtein("test", "tent")).toBe(1);
});
test("one insertion", () => {
expect(levenshtein("test", "tests")).toBe(1);
});
test("one deletion", () => {
expect(levenshtein("tests", "test")).toBe(1);
});
test("completely different strings", () => {
expect(levenshtein("abc", "xyz")).toBe(3);
});
test("one empty string", () => {
expect(levenshtein("", "test")).toBe(4);
});
test("both empty strings", () => {
expect(levenshtein("", "")).toBe(0);
});
});
describe("suggestModels", () => {
test("exact match", () => {
const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2");
});
test("fuzzy match/typo", () => {
expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
});
test("fuzzy match with contains boost", () => {
const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]);
expect(suggestions[0]).toBe("gpt-5.2");
});
test("maximum of 3 suggestions", () => {
const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"];
expect(suggestModels("gpt4o", allowlist).length).toBe(3);
});
test("sorting logic", () => {
const suggestions = suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["abc", "abd", "abe"]);
});
});
describe("formatInvalidModelError", () => {
test("single invalid model with available suggestions", () => {
const error = formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-5.2"]);
expect(error).toContain("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean");
});
test("single invalid model without close matches", () => {
const error = formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean");
});
test("multiple invalid models", () => {
const error = formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found");
expect(error).toContain("'xyz'");
expect(error).toContain("'abc'");
});
});
describe("sanitizeName", () => {
test("normal alphanumeric name", () => {
expect(sanitizeName("test-session")).toBe("test-session");
});
test("name with spaces and special chars", () => {
expect(sanitizeName("test session!")).toBe("test-session");
});
test("name with consecutive hyphens", () => {
expect(sanitizeName("test--session")).toBe("test-session");
});
test("name with leading/trailing hyphens", () => {
expect(sanitizeName("-test-session-")).toBe("test-session");
});
});
describe("runCommand", () => {
test("returns ok true for successful command", async () => {
mockCommandResponses["echo hello"] = { ok: true, stdout: "hello", stderr: "" };
const result = await runCommand(["echo", "hello"]);
expect(result.ok).toBe(true);
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
});
test("returns ok false for failed command", async () => {
mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" };
const result = await runCommand(["false"]);
expect(result.ok).toBe(false);
expect(result.stderr).toBe("error");
});
});
describe("getWorktreePath", () => {
test("returns correct worktree path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("my-session", "gpt-5.2");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
});
test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/parent/child/window");
});
});
describe("getSessionPath", () => {
test("returns correct session path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getSessionPath("my-session");
expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session");
});
});
describe("undoWorktree", () => {
test("returns empty array on success", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors).toEqual([]);
});
test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree");
});
test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch");
});
test("returns both errors when both operations fail", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" };
mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" };
const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
expect(errors.length).toBe(2);
});
});
describe("launchModelInWindow", () => {
test("sends correct tmux command", async () => {
mockCommandResponses["tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" });
expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m");
});
test("uses custom binary name", async () => {
mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", { model: "openai/gpt-5.2", windowName: "window" }, "kilo");
expect(result.ok).toBe(true);
expect(executedCommands).toContain("tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m");
});
});
describe("getWorktreesForSession", () => {
test("returns session-specific worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
});
test("sanitizes session name when filtering worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
stderr: "",
};
const worktrees = await getWorktreesForSession("my session!");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2");
});
test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees).toEqual([]);
});
});
describe("getBinaryName", () => {
test("returns default 'opencode' when env var not set", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode");
});
test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo");
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
});
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": ["bun-types"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}