mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49da7c4219 | ||
|
|
41757e712f | ||
|
|
96e3922c42 | ||
|
|
d6a2811ec4 | ||
|
|
c179491aed | ||
|
|
1b8f54d955 | ||
|
|
7cecbede6d | ||
|
|
05dc93b1ed | ||
|
|
d03e14178b | ||
|
|
5a56ed421d | ||
|
|
7e5eab32a6 | ||
|
|
d34401bc0e | ||
|
|
29e32ca0f1 | ||
|
|
bfd55e0f9f | ||
|
|
5593bc8cc9 | ||
|
|
61e8d1491d | ||
|
|
ca753cd1ca |
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,500 +1,6 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
// Re-export open and close tools for project-level use via relative paths.
|
||||
// For published package usage, change imports to "@username/opencode-multi-model".
|
||||
import { openTool } from "../../src/tools/open";
|
||||
import { closeTool } from "../../src/tools/close";
|
||||
|
||||
type WindowLaunchPlan = {
|
||||
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,
|
||||
};
|
||||
export { openTool as open, closeTool as close };
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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
|
||||
│ │ ├── launch.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
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@username/opencode-multi-model",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.27",
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "opencode-multi-model",
|
||||
"version": "0.0.3",
|
||||
"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",
|
||||
"prepublishOnly": "bun run build",
|
||||
"release": "bash ./scripts/release.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
}
|
||||
Executable
+39
@@ -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."
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Command } from "commander";
|
||||
import { launchMultiModel } from "./core/launch";
|
||||
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 launchMultiModel({
|
||||
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("-f, --force", "Skip confirmation prompts", false)
|
||||
.action(async (sessionName: string, options: { cleanupWorktrees: boolean; force: boolean }) => {
|
||||
try {
|
||||
const result = await closeMultiModel({
|
||||
sessionName,
|
||||
cleanupWorktrees: options.cleanupWorktrees,
|
||||
force: options.force,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Closed session: ${result.sessionName}`);
|
||||
if (result.cleanupPerformed) {
|
||||
console.log(` Removed ${result.worktreesRemoved?.length || 0} worktrees`);
|
||||
if (result.tagsCreated && result.tagsCreated.length > 0) {
|
||||
console.log(` Created backup tags: ${result.tagsCreated.join(", ")}`);
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
console.log(` Warnings: ${result.warnings.join("; ")}`);
|
||||
}
|
||||
if (result.branchesDeleted && result.branchesDeleted.length > 0) {
|
||||
console.log(` Deleted ${result.branchesDeleted.length} branches`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`Failed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as readline from "node:readline";
|
||||
import type { CloseSessionOptions, CloseSessionResult } from "../types";
|
||||
import { runCommand, getSessionPath, getWorktreesForSession } 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 branchesDeleted: string[] = [];
|
||||
const tagsCreated: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if session exists
|
||||
const { ok: sessionExists } = await runCommand(["tmux", "has-session", "-t", options.sessionName]);
|
||||
|
||||
// Kill tmux session if it exists
|
||||
if (sessionExists) {
|
||||
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
|
||||
}
|
||||
|
||||
// Get list of worktrees for this session before killing tmux
|
||||
const worktrees = await getWorktreesForSession(options.sessionName);
|
||||
|
||||
// If cleanup requested and not forced, ask for confirmation
|
||||
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
|
||||
console.log(`\nThe following worktrees and branches will be removed:`);
|
||||
worktrees.forEach((wt) => console.log(` - ${wt.path} (branch: ${wt.branch})`));
|
||||
|
||||
const answer = await promptUser("\nDo you want to proceed? (y/N): ");
|
||||
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
|
||||
return {
|
||||
success: false,
|
||||
sessionName: options.sessionName,
|
||||
error: "Cleanup cancelled by user",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Create an archive tag before deleting the branch for safe recovery
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
|
||||
const tagResult = await runCommand(["git", "tag", archiveTag, worktree.branch]);
|
||||
|
||||
if (tagResult.ok) {
|
||||
tagsCreated.push(archiveTag);
|
||||
} else {
|
||||
const warningMsg = `Failed to create tag ${archiveTag} for branch ${worktree.branch}.`;
|
||||
warnings.push(warningMsg);
|
||||
console.warn(`Warning: ${warningMsg}`);
|
||||
}
|
||||
|
||||
await runCommand(["git", "branch", "-D", worktree.branch]);
|
||||
branchesDeleted.push(worktree.branch);
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Failed to cleanup worktree ${worktree.path}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove the base directory
|
||||
const worktreeBase = getSessionPath(options.sessionName);
|
||||
try {
|
||||
await runCommand(["rm", "-rf", worktreeBase]);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
cleanupPerformed = worktreesRemoved.length > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionName: options.sessionName,
|
||||
cleanupPerformed,
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
sessionName: options.sessionName,
|
||||
error: String(error),
|
||||
worktreesRemoved,
|
||||
branchesDeleted,
|
||||
tagsCreated,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { launchMultiModel } from "./launch";
|
||||
export { closeMultiModel } from "./close";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as fs from "node:fs";
|
||||
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 launchMultiModel({
|
||||
* sessionName: "compare",
|
||||
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
|
||||
* binaryName: "opencode",
|
||||
* });
|
||||
* if (result.success) {
|
||||
* console.log(result.instructions);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function launchMultiModel(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: [
|
||||
`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 when done using either:`,
|
||||
`1. ${primaryCleanup}`,
|
||||
`2. Or run manually: \`${cleanupCommand}\``,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionName,
|
||||
windows: windowNames,
|
||||
instructions: [
|
||||
`Use \`${attachCommand}\` to join session.`,
|
||||
`When finished, clean up using either:`,
|
||||
`1. ${primaryCleanup}`,
|
||||
`2. Or run manually: \`${cleanupCommand}\``,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -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(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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,41 @@
|
||||
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)"),
|
||||
},
|
||||
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,
|
||||
force: true,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result.error!;
|
||||
}
|
||||
|
||||
let details = "";
|
||||
if (result.cleanupPerformed) {
|
||||
details += ` Removed ${result.worktreesRemoved?.length || 0} worktrees.`;
|
||||
if (result.tagsCreated && result.tagsCreated.length > 0) {
|
||||
details += ` Created backup tags: ${result.tagsCreated.join(", ")}.`;
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
details += ` Warnings: ${result.warnings.join("; ")}.`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Session "${result.sessionName}" has been closed.${details}`;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { launchMultiModel } from "../core/launch";
|
||||
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 launchMultiModel({
|
||||
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`;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 {
|
||||
/** 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 {
|
||||
/** 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[];
|
||||
/** Warning messages. */
|
||||
warnings?: 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;
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { launchMultiModel } from "../src/core/launch";
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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 launchMultiModel({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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("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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user