Compare commits

..
Author SHA1 Message Date
bendtherules 21703fa2f8 chore(release): 0.0.10 2026-03-23 09:58:48 +05:30
bendtherules 4f75a920fc Merge branch 'opencode/build-cleanup/kimi-k2-5' 2026-03-23 09:56:41 +05:30
bendtherules 92708cf68b chore(deps): bump @opencode-ai/plugin from 1.2.27 to 1.3.0 2026-03-23 09:56:38 +05:30
bendtherules 6e2521b70d feat: add multi-model cleanup tool and CLI command
- Bump `@opencode-ai/plugin` dependency from 1.2.27 to 1.3.0.
- Introduce `cleanup` command in CLI with force and remote options.
- Export `cleanupMultiModel` from core index.
- Register `multi-model-cleanup` tool in plugin and update exports.
- Add `CleanupOptions` and `CleanupResult` types to define cleanup behavior and results.
2026-03-23 09:43:43 +05:30
bendtherules 42f153a28f plan: cleanup - streamline remote option handling 2026-03-23 09:33:39 +05:30
bendtherules ff065300ad feat: add TypeScript typecheck\ 2026-03-23 09:25:33 +05:30
bendtherules 36c6700437 fix: Fix biome correctness errors 2026-03-23 08:32:30 +05:30
bendtherules 7a164c705c build: add biome formatter and linter 2026-03-23 08:17:27 +05:30
bendtherules 068db04355 fix: Simplify remote handling in cleanup command 2026-03-23 07:54:09 +05:30
bendtherules dc60dc674e plan: cleanup command 2026-03-23 07:22:44 +05:30
bendtherules 8210080b43 feat: Add setup script for configuring git hooks 2026-03-21 23:28:51 +05:30
bendtherules 545f06b293 build: Add post-checkout hook to run bun install 2026-03-21 23:25:30 +05:30
bendtherules 3bcdc0a90f chore(release): 0.0.9 2026-03-21 18:00:37 +05:30
bendtherules 132a15dfc4 chore(release): 0.0.8 2026-03-21 18:00:29 +05:30
bendtherules 8bbd741bfa chore(release): 0.0.7 2026-03-21 17:55:26 +05:30
bendtherules 0503c474a8 fix: use sessionName instead of safeSessionName in tmux commands 2026-03-21 17:42:38 +05:30
bendtherules d92c9623a8 fix: remove package-lock.json to streamline dependencies 2026-03-21 17:33:37 +05:30
bendtherules 4364910fca Merge branch 'opencode/build-readme-improve/glm-5' 2026-03-21 17:31:34 +05:30
bendtherules f956406e9d docs: add CLI reference and How it works to Readme 2026-03-21 17:31:07 +05:30
bendtherules b92fdc692b fix: remove default value for --no-tags option 2026-03-21 17:29:09 +05:30
bendtherules 81b25bd348 fix: close cli options - improve negative options 2026-03-21 16:42:24 +05:30
bendtherules b2621a10de fix: console logs branch names directly for close confirmation 2026-03-21 16:41:54 +05:30
bendtherules fc7feb2095 chore(release): 0.0.6 2026-03-21 01:28:15 +05:30
bendtherules 7180569b87 feat: add option to disable archive tags 2026-03-21 01:27:43 +05:30
bendtherules f0cc07f513 chore(release): 0.0.5 2026-03-21 01:11:36 +05:30
bendtherules 6dd4ee4b4f fix: correct path for cli.js in package.json 2026-03-21 01:11:30 +05:30
bendtherules dd7ac4dc2d chore(release): 0.0.4 2026-03-21 01:10:27 +05:30
bendtherules e8dbcff73e fix test 2026-03-21 01:10:21 +05:30
bendtherules 787fa75df4 build: color-code open and close outputs 2026-03-21 01:05:17 +05:30
bendtherules 17bb1130c8 build: color-code open output 2026-03-21 00:22:31 +05:30
bendtherules c642148cac test: add user confirmation handling for cleanup in closeMultiModel 2026-03-20 14:56:29 +05:30
bendtherules 7abfd6bf64 test: fix tests 2026-03-20 14:46:00 +05:30
bendtherules 93478294aa test: add error handling tests for closeMultiModel and openMultiModel 2026-03-20 14:20:34 +05:30
bendtherules 5d50038845 docs: update test coverage command in README 2026-03-20 14:08:00 +05:30
bendtherules 0b88ea0763 docs: add local code coverage instructions 2026-03-20 14:02:41 +05:30
bendtherules ffdc5451ab refactor: tests and add close.test.ts 2026-03-20 13:51:28 +05:30
bendtherules 8a087275b3 refactor: rename launchMultiModel to openMultiModel 2026-03-20 13:38:46 +05:30
bendtherules 962c9a474f refactor: rename src/core/launch.ts to open.ts 2026-03-20 13:33:30 +05:30
bendtherules 71523f2144 feat: add launch configuration for multi-model sessions in VSCode 2026-03-20 13:29:36 +05:30
bendtherules 1c2f203a34 feat: add initial VSCode settings for Bun configuration 2026-03-20 13:22:00 +05:30
bendtherules 536e30c5d0 test: add session name sanitization when fetching worktrees 2026-03-20 13:21:51 +05:30
bendtherules da0ed90192 fix: sanitize session name in getWorktreesForSession function 2026-03-20 13:17:10 +05:30
bendtherules 49da7c4219 chore(release): 0.0.3 2026-03-20 10:42:09 +05:30
bendtherules 41757e712f fix: update package name to remove username prefix 2026-03-20 10:41:58 +05:30
bendtherules 96e3922c42 chore(release): 0.0.2 2026-03-20 10:39:56 +05:30
bendtherules d6a2811ec4 fix: update release script permissions to executable 2026-03-20 10:39:48 +05:30
bendtherules c179491aed build: add release script 2026-03-20 10:38:07 +05:30
bendtherules 1b8f54d955 fix: revert version number to 0.0.1 in package.json 2026-03-20 10:35:36 +05:30
bendtherules 7cecbede6d fix: update npm @username to @bendtherules 2026-03-20 10:33:39 +05:30
bendtherules 05dc93b1ed fix: update model references from gpt-4o to gpt-5-2 in utils and tests 2026-03-20 10:23:55 +05:30
bendtherules d03e14178b fix: update model references from gpt-4o to gpt-5.2 in README and tests 2026-03-20 10:17:11 +05:30
bendtherules 5a56ed421d Add more tests 2026-03-20 10:15:25 +05:30
bendtherules 7e5eab32a6 delete: remove old multi-model test file 2026-03-20 10:10:39 +05:30
bendtherules d34401bc0e fix: streamline tmux session termination and cleanup logic 2026-03-20 08:34:38 +05:30
bendtherules 29e32ca0f1 build: Fix branch name delete 2026-03-19 20:07:17 +05:30
bendtherules bfd55e0f9f build: add direct cleanup command 2026-03-19 19:41:25 +05:30
bendtherules 5593bc8cc9 build: Fix tool name as local tool 2026-03-19 19:28:06 +05:30
bendtherules 61e8d1491d build: remove tsc 2026-03-19 19:20:39 +05:30
bendtherules ca753cd1ca Impl: tool-npm-package 2026-03-19 19:12:50 +05:30
35 changed files with 5181 additions and 1082 deletions
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Post-checkout hook to run bun install when switching branches or checking out files
# This ensures dependencies are always up to date
# Only run if package.json or bun.lock exist (indicating a Node/Bun project)
if [ -f "package.json" ] || [ -f "bun.lock" ]; then
echo "Running bun install..."
bun install
fi
-527
View File
@@ -1,527 +0,0 @@
import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import multiModelTool, { __testing_helpers } from "./tools/multi-model";
let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<string, { ok: boolean; stdout: string; stderr: string }> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" };
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model tool", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" },
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" },
"opencode models": { ok: true, stdout: "openai/gpt-4o\nanthropic/claude-3-5-sonnet", stderr: "" },
"tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" },
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o": { ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await multiModelTool.execute(
{ sessionName: "", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: [] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `multi-model` requires a git repository.");
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `tmux` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux"
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: `opencode` is not installed or not on `PATH`.");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode"
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["invalid/model"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Model name 'invalid/model' not found");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models"
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-4o"))).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test session!", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test session!` to join session");
expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-4o"))).toBe(true);
});
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
mockCommandResponses["tmux new-session -d -s test-session -n gpt-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o",
"git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"tmux new-session -d -s test-session -n gpt-4o -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o",
"git branch -D opencode/test-session/gpt-4o"
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-4o");
});
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session"
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: true, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Branch 'opencode/test-session/gpt-4o' already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses["git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "git error" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o",
"git worktree add -b opencode/test-session/gpt-4o /mock/home/.local/share/opencode/multi-model/test-session/gpt-4o"
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-4o\nanthropic/gpt-4o", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o"] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-4o-2"] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: ["openai/gpt-4o", "anthropic/gpt-4o"] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-4o"))).toBe(true);
expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-4o-2"))).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" };
mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" };
const result = await multiModelTool.execute(
{ sessionName: "test-session", models: [longModel] },
{ metadata: mock() } as any
);
expect(result).toContain("Use `tmux attach -t test-session` to join session");
const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
describe("__testing_helpers", () => {
describe("shellQuote", () => {
test("normal string without special characters", () => {
expect(__testing_helpers.shellQuote("test")).toBe("'test'");
});
test("string with spaces", () => {
expect(__testing_helpers.shellQuote("test value")).toBe("'test value'");
});
test("string with single quotes", () => {
expect(__testing_helpers.shellQuote("test'value")).toBe("'test'\"'\"'value'");
});
test("string with multiple single quotes", () => {
expect(__testing_helpers.shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'");
});
test("empty string", () => {
expect(__testing_helpers.shellQuote("")).toBe("''");
});
});
describe("normalizeModels", () => {
test("empty array", () => {
expect(__testing_helpers.normalizeModels([])).toEqual([]);
});
test("undefined input", () => {
expect(__testing_helpers.normalizeModels(undefined)).toEqual([]);
});
test("array with whitespace-only strings", () => {
expect(__testing_helpers.normalizeModels([" ", " "])).toEqual([]);
});
test("array with mixed valid models, padded models, and empty strings", () => {
expect(__testing_helpers.normalizeModels([" openai/gpt-4o ", "", " anthropic/claude "])).toEqual(["openai/gpt-4o", "anthropic/claude"]);
});
});
describe("findDuplicates", () => {
test("array with no duplicates", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "c"])).toEqual([]);
});
test("array with one duplicate pair", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "a"])).toEqual(["a"]);
});
test("array with multiple different duplicates", () => {
expect(__testing_helpers.findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
});
test("array with a single value repeated more than twice", () => {
expect(__testing_helpers.findDuplicates(["a", "a", "a"])).toEqual(["a"]);
});
test("verifying first-repeated-occurrence order", () => {
expect(__testing_helpers.findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]);
});
});
describe("createWindowBaseName", () => {
test("normal model name without slashes", () => {
expect(__testing_helpers.createWindowBaseName("gpt-4o")).toBe("gpt-4o");
});
test("model with slashes", () => {
expect(__testing_helpers.createWindowBaseName("vendor/namespace/model")).toBe("model");
});
test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30);
expect(__testing_helpers.createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
});
test("model with special characters", () => {
expect(__testing_helpers.createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
});
test("model made entirely of special characters", () => {
expect(__testing_helpers.createWindowBaseName("!@#$%^&*()")).toBe("model");
});
});
describe("createWindowPlans", () => {
test("single model", () => {
expect(__testing_helpers.createWindowPlans(["openai/gpt-4o"])).toEqual([{ model: "openai/gpt-4o", windowName: "gpt-4o" }]);
});
test("two models with the same base name", () => {
expect(__testing_helpers.createWindowPlans(["openai/gpt-4o", "anthropic/gpt-4o"])).toEqual([
{ model: "openai/gpt-4o", windowName: "gpt-4o" },
{ model: "anthropic/gpt-4o", windowName: "gpt-4o-2" }
]);
});
test("multiple collisions", () => {
const result = __testing_helpers.createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3");
});
test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23);
const result = __testing_helpers.createWindowPlans([longBase, "b"]);
expect(result[1]?.windowName.length).toBeLessThanOrEqual(24);
});
});
describe("levenshtein", () => {
test("identical strings", () => {
expect(__testing_helpers.levenshtein("test", "test")).toBe(0);
});
test("one substitution", () => {
expect(__testing_helpers.levenshtein("test", "tent")).toBe(1);
});
test("one insertion", () => {
expect(__testing_helpers.levenshtein("test", "tests")).toBe(1);
});
test("one deletion", () => {
expect(__testing_helpers.levenshtein("tests", "test")).toBe(1);
});
test("completely different strings", () => {
expect(__testing_helpers.levenshtein("abc", "xyz")).toBe(3);
});
test("one empty string", () => {
expect(__testing_helpers.levenshtein("", "test")).toBe(4);
});
test("both empty strings", () => {
expect(__testing_helpers.levenshtein("", "")).toBe(0);
});
});
describe("suggestModels", () => {
test("exact match", () => {
const result = __testing_helpers.suggestModels("gpt-4o", ["gpt-4o", "gpt-4o-mini"]);
expect(result[0]).toBe("gpt-4o");
});
test("fuzzy match/typo", () => {
expect(__testing_helpers.suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
});
test("fuzzy match with contains boost", () => {
const suggestions = __testing_helpers.suggestModels("gpt4o", ["gpt-4o", "gpt-4o-mini"]);
expect(suggestions[0]).toBe("gpt-4o");
});
test("maximum of 3 suggestions", () => {
const allowlist = ["gpt-4o", "gpt-4o-mini", "gpt-4o-pro", "gpt-4o-ultra"];
expect(__testing_helpers.suggestModels("gpt4o", allowlist).length).toBe(3);
});
test("sorting logic", () => {
const suggestions = __testing_helpers.suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["abc", "abd", "abe"]);
});
});
describe("formatInvalidModelError", () => {
test("single invalid model with available suggestions", () => {
const error = __testing_helpers.formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-4o"]);
expect(error).toContain("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean");
});
test("single invalid model without close matches", () => {
const error = __testing_helpers.formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean");
});
test("multiple invalid models", () => {
const error = __testing_helpers.formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found");
expect(error).toContain("'xyz'");
expect(error).toContain("'abc'");
});
});
describe("sanitizeName", () => {
test("normal alphanumeric name", () => {
expect(__testing_helpers.sanitizeName("test-session")).toBe("test-session");
});
test("name with spaces and special chars", () => {
expect(__testing_helpers.sanitizeName("test session!")).toBe("test-session");
});
test("name with consecutive hyphens", () => {
expect(__testing_helpers.sanitizeName("test--session")).toBe("test-session");
});
test("name with leading/trailing hyphens", () => {
expect(__testing_helpers.sanitizeName("-test-session-")).toBe("test-session");
});
});
});
-52
View File
@@ -1,52 +0,0 @@
{
"name": "opencode-multi-model-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencode-multi-model-tools",
"dependencies": {
"@opencode-ai/plugin": "1.2.24"
},
"devDependencies": {
"@types/node": "^25.5.0"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.24",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.24",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.24",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.27" "@opencode-ai/plugin": "1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.10", "@types/bun": "^1.3.10",
@@ -0,0 +1,475 @@
---
model: opencode-go/glm-5
---
# Plan: Add `opencode-multi-model cleanup` Command
## Overview
Add a new`cleanup` command that deletes all archive tags (format: `archive/*`) from localand remote git repositories. Archive tags are created by the `close` command to preserve branch state before deletion.
## User Requirements
1. **Scope**: Only archive tags (not orphaned worktrees/branches)
2. **Confirmation**: Prompt by default, `--force` to skip
3. **Remote handling**: Delete from both local and remote by default
4. **Preview**: Show count of tags, not full list
5. **Remote discovery**: Check `git remote -v` for available remotes
6. **Error handling**: Continue with local cleanup if remote deletion fails
## Files to Modify/Create
| File | Action | Description |
|------|--------|-------------|
| `src/types.ts` | MODIFY | Add `CleanupOptions` and `CleanupResult` interfaces |
| `src/core/cleanup.ts` | CREATE | Core cleanup implementation |
| `src/tools/cleanup.ts` | CREATE | Tool definition for plugin mode |
| `src/cli.ts` | MODIFY | Add cleanup command registration |
| `src/index.ts` | MODIFY | Export cleanup tool |
| `tests/cleanup.test.ts` | CREATE | Unit tests |
---
## Implementation Details
### 1. TypeScript Interfaces(`src/types.ts`)
Add after `WorktreeInfo` interface (around line 98):
```typescript
/**
* Options for cleaning up archive tags.
*/
export interface CleanupOptions {
/** Skip confirmation prompts. */
force?: boolean;
/**
* Remote name to push deletions to.
* - If string: use specified remote (error if doesn't exist)
* - If undefined: use first available remote from git remote -v
* - If false: only local cleanup (--no-remote flag)
*/
remote?: string | false;
}
/**
* Result returned after attempting to cleanup archive tags.
*/
export interface CleanupResult {
/** Whether the cleanup succeeded. */
success: boolean;
/** Error message if the cleanup failed. */
error?: string;
/** Summary message for the user. */
instructions?: string;
/** Number of local tags deleted. */
localTagsDeleted: number;
/** Number of remote tags deleted. */
remoteTagsDeleted: number;
/** Names of tags that were deleted locally. */
localTagNames: string[];
/** Names of tags deleted from remote. */
remoteTagNames: string[];
/** Tags that failed remote deletion. */
remoteTagErrors: Array<{ tag: string; error: string }>;
/** Available remotes discovered. */
availableRemotes: string[];
}
```
---
### 2. Core Implementation (`src/core/cleanup.ts`)
Create new file with the following functions:
#### Helper Functions
```typescript
import * as readline from "node:readline";
import chalk from 'chalk';
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
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);
});
});
}
/**
* Lists all archive tags matching the pattern archive/*.
*/
export async function listArchiveTags(): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Gets the list of available git remotes.
*/
export async function getAvailableRemotes(): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(tagName: string, remoteName: string): Promise<{ ok: boolean; error: string }> {
const result = await runCommand(["git", "push", remoteName, "--delete", tagName]);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
};
}
```
#### Main Function
```typescript
/**
* Cleans up archive tags from local and optionally remote repositories.
*
* Archive tags have the format: archive/<branch-name>-<timestamp>
* These are created by the close command to preserve branch state before deletion.
*
* @param options Cleanup configuration including force mode and remote settings.
* @returns Result with counts and details of deleted tags.
*/
export async function cleanupArchiveTags(options: CleanupOptions = {}): Promise<CleanupResult> {
const { force = false, remote } = options;
// Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes();
const archiveTags = await listArchiveTags();
// Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode
// - remote === string: use specified remote (error if doesn't exist)
// - remote === undefined: use first available remote
const shouldPushToRemote = remote !== false;
let remoteToDeleteFrom: string | undefined;
if (shouldPushToRemote) {
if (typeof remote === "string") {
// User specified a remote - validate it exists
if (!availableRemotes.includes(remote)) {
return {
success: false,
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
remoteToDeleteFrom = remote;
} else {
// remote === undefined, use first available
remoteToDeleteFrom = availableRemotes[0];
}
}
if (archiveTags.length === 0) {
return {
success: true,
instructions: chalk.dim("No archive tags found."),
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
// Confirmation prompt (unless --force)
if (!force) {
const tagCount = archiveTags.length;
const tagWord = tagCount === 1 ? "tag" : "tags";
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
if (shouldPushToRemote && remoteToDeleteFrom) {
console.log(chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`));
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
console.log(chalk.dim("No remotes configured. Only local cleanup will be performed."));
}
const answer = await promptUser(chalk.bold("\nProceed with cleanup? (y/N): "));
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
error: "Cleanup cancelled by user",
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
}
const localTagNames: string[] = [];
const remoteTagNames: string[] = [];
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
if (deleted) {
localTagNames.push(tag);
}
}
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
if (result.ok) {
remoteTagNames.push(tag);
} else {
// Continue with local cleanup even if remote fails
remoteTagErrors.push({ tag, error: result.error });
}
}
}
// Build instructions message
const instructionsArr: string[] = [];
if (localTagNames.length > 0) {
const word = localTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`));
}
if (remoteTagNames.length > 0) {
const word = remoteTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`));
}
if (remoteTagErrors.length > 0) {
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
instructionsArr.push(chalk.yellow(`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`));
}
return {
success: true,
instructions: instructionsArr.join("\n"),
localTagsDeleted: localTagNames.length,
remoteTagsDeleted: remoteTagNames.length,
localTagNames,
remoteTagNames,
remoteTagErrors,
availableRemotes,
};
}
```
---
### 3. Tool Definition (`src/tools/cleanup.ts`)
Create new file:
```typescript
import { tool } from "@opencode-ai/plugin";
import { cleanupArchiveTags } from "../core/cleanup";
/**
* OpenCode tool definition for cleaning up archive tags.
*
* Deletes archive tags (format: archive/*) from local and remote repositories.
* Used to clean up recovery tags created by the close command.
*/
export const cleanupTool = tool({
description: "Delete all archive tags from local and optionally remote repository",
args: {
remote: tool.schema.union([
tool.schema.string().describe("Remote name to push deletions to. Error if doesn't exist."),
tool.schema.literal(false).describe("Only delete local tags, don't push to remote."),
]).optional().describe("Remote handling: string=specific remote, false=local-only, undefined=first available remote"),
},
async execute(args, context) {
// In plugin mode, we skip confirmation (force=true)
const result = await cleanupArchiveTags({
force: true,
remote: args.remote,
});
if (!result.success) {
return result.error!;
}
return result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`;
},
});
```
---
### 4. CLI Command (`src/cli.ts`)
Add import after line 5:
```typescript
import { cleanupArchiveTags } from "./core/cleanup";
```
Add command after line 69 (after close command):
```typescript
program
.command("cleanup")
.description("Delete all archive tags from local and remote")
.option("-f, --force", "Skip confirmation prompts", false)
.option("-r, --remote <remote>", "Remote name to push deletions to (default: first remote from git remote -v)")
.option("--no-remote", "Only delete local tags, skip remote")
.action(async (options: { force: boolean; remote?: string|boolean }) => {
try {
const result = await cleanupArchiveTags({
force: options.force,
remote: options.remote,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
```
---
### 5. Index Export (`src/index.ts`)
Add import after line 3:
```typescript
import { cleanupTool } from "./tools/cleanup";
```
Update return object (line 22-25):
```typescript
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
},
```
Update exports (line30):
```typescript
export { openTool, closeTool, cleanupTool };
```
---
## Usage Examples
### CLI Usage
```bash
# Interactive (shows confirmation prompt, uses first remote from git remote -v)
opencode-multi-model cleanup
# Skip confirmation
opencode-multi-model cleanup --force
# Use specific remote
opencode-multi-model cleanup --remote upstream
# Only local cleanup (don't push to remote)
opencode-multi-model cleanup --no-remote
# Combined options
opencode-multi-model cleanup -f --remote upstream
```
### Tool Usage (OpenCode Plugin)
```typescript
// Local-only cleanup (don't push to remote)
await multi-model-cleanup({ remote: false });
// Use first available remote (default behavior)
await multi-model-cleanup({ remote: undefined });
// Specify a remote
await multi-model-cleanup({ remote: "upstream" });
```
---
## Git Commands Used
| Command | Purpose |
|---------|---------|
| `git tag -l "archive/*"` | List all archive tags |
| `git remote` | Get available remotes |
| `git tag -d <name>` | Delete local tag |
| `git push <remote> --delete <tag>` | Delete remote tag |
---
## Verification
After implementation, verify with:
1. **Build**: `bun run build` or `bun run typecheck`
2. **Unit tests**: `bun test tests/cleanup.test.ts`
3. **Manual test**:
```bash
# Create some archive tags for testing
git tag archive/test-branch-1-2024-01-01T00-00-00-000Z
git tag archive/test-branch-2-2024-01-02T00-00-00-000Z
# Run cleanup
bun run src/cli.ts cleanup
# Verify tags are deleted
git tag -l "archive/*"
```
---
## Test Cases
Create `tests/cleanup.test.ts` with:
1. No archive tags found - returns success with message
2. User cancels confirmation - returns success=false
3. Force mode skips confirmation
4. **--no-remote flag (remote=false) - only local cleanup, no remote push**
5. Remote deletion failure continues local cleanup
6. **Dynamic remote detection - remote=undefined uses first available remote**
7. **Remote name validation - uses specified remote if it exists**
8. **Remote error - returns error if specified remote doesn't exist**
9. No remotes configured - only local cleanup
+5 -498
View File
@@ -1,500 +1,7 @@
import { tool } from "@opencode-ai/plugin"; // Re-export open and close tools for project-level use via relative paths.
import * as os from "node:os"; // For published package usage, change imports to "@username/opencode-multi-model".
import * as path from "node:path";
import * as fs from "node:fs";
type WindowLaunchPlan = { import { closeTool } from "../../src/tools/close";
model: string; import { openTool } from "../../src/tools/open";
windowName: string;
};
type CommandResult = { export { closeTool as close, openTool as open };
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,
};
+2 -4
View File
@@ -18,9 +18,7 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"types": [ "types": ["bun-types"]
"bun-types"
]
}, },
"include": ["**/*.ts", "**/*.tsx"] "include": ["**/*.ts", "**/*.tsx"]
} }
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Open Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["open", "test/session1", "--models", "openai/gpt-5.2"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
},
{
"type": "node",
"request": "launch",
"name": "Close Multi-Model Session",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/cli.ts",
"args": ["close", "test/session1"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
// The path to the `bun` executable.
"bun.runtime": "/path/to/bun",
// If support for Bun should be added to the default "JavaScript Debug Terminal".
"bun.debugTerminal.enabled": true,
// If the debugger should stop on the first line of the program.
"bun.debugTerminal.stopOnEntry": false,
// Glob pattern to find test files. Defaults to the value shown below.
"bun.test.filePattern": "**/*{.test.,.spec.,_test_,_spec_}{js,ts,tsx,jsx,mts,cts,cjs,mjs}",
// The custom script to call for testing instead of `bun test`
"bun.test.customScript": "bun test"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+260
View File
@@ -0,0 +1,260 @@
# 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")
## CLI Reference
### `open` - Create a new multi-model tmux session
```bash
opencode-multi-model open <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name for the tmux session (required)
**Options:**
| Option | Description |
|--------|-------------|
| `-m, --models <models...>` | Model IDs to launch (space-separated) |
| `-b, --binary <binary>` | Binary to use (opencode or kilo). Defaults to env var `OPENCODE_MULTI_MODEL_BINARY` or 'opencode' |
### `close` - Close a multi-model tmux session
```bash
opencode-multi-model close <session-name> [options]
```
**Arguments:**
- `<session-name>` - Name of the tmux session to close (required)
**Options:**
| Option | Description |
|--------|-------------|
| `--keep-worktrees` | Do not remove worktrees and delete branches |
| `--no-tags` | Do not create archive tags before deleting branches |
| `-f, --force` | Skip confirmation prompts |
### Global Options
| Option | Description |
|--------|-------------|
| `-h, --help` | Display help information |
| `-V, --version` | Display version number |
## How it works
### `open` command
The `open` command performs the following steps:
1. **Validation**: Checks that session name andmodels are provided, no duplicate models exist, and verifies git repo, tmux, and binary are available
2. **Model verification**: Validates models against `opencode models` output to ensure only valid model IDs are used
3. **Git worktree creation**: For each model, creates:
- A git worktree at `~/.local/share/opencode/multi-model/<session>/<model>/`
- A branch named `opencode/<session>/<model>`
4. **Tmux session**: Creates a tmux session with one window per model, each window's working directory set to its corresponding worktree
5. **Launch**: Sends the launch command to each window to start the binary with the specified model
### `close` command
The `close` command performs the following steps:
1. **Kill tmux session**: Terminates the tmux session if it exists
2. **Worktree discovery**: Finds all worktrees under `~/.local/share/opencode/multi-model/<session>/`
3. **Worktree cleanup** (unless `--keep-worktrees`):
- Removes each gitworktree
- Creates an archive tag (unless `--no-tags`): `archive/<branch>-<timestamp>` for recovery
- Deletes the associated branches
4. **Directory cleanup**: Removes the base session directory
## Requirements
- tmux
- git
- opencode or kilo binary
- Bun runtime
## Development
For local development and testing without publishing:
```bash
# Install dependencies
bun install
# Setup git hooks
bun run setup
# Build
bun run build
# Run tests
bun test
# Test CLI locally
bun dist/cli.js open test-session -m openai/gpt-5.2
bun dist/cli.js close test-session
```
## Architecture
```
├── src/
│ ├── index.ts # Plugin entry point
│ ├── cli.ts # CLI entry point
│ ├── types.ts # Shared TypeScript types
│ ├── core/
│ │ ├── index.ts # Core exports
│ │ ├── open.ts # Launch session logic
│ │ ├── close.ts # Close session logic
│ │ └── utils.ts # Helper functions
│ └── tools/
│ ├── open.ts # Open tool definition
│ └── close.ts # Close tool definition
├── tests/
│ └── multi-model.test.ts # Tests
├── package.json
├── tsconfig.json
└── README.md
```
## Local code coverage
The test suite can generate a coverage report locally.
```bash
# Run tests with coverage (HTML report generated in ./coverage)
npm run coverage # or: bun run coverage
# Open the HTML report
open coverage/index.html # macOS
```
The `coverage` directory is ignored by Git but can be inspected to see which lines are exercised.
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"correctness": {
"noUnusedImports": "warn",
"noUnusedFunctionParameters": "warn",
"noUnusedVariables": "warn",
"noConstructorReturn": "warn"
},
"complexity": {
"useLiteralKeys": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@username/opencode-multi-model",
"dependencies": {
"@opencode-ai/plugin": "^1.2.27",
"chalk": "^5.3.0",
"commander": "^12.0.0",
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"name": "opencode-multi-model",
"version": "0.0.10",
"description": "Launch multiple AI models in tmux sessions - OpenCode Plugin, CLI, and Project Tool",
"type": "module",
"main": "./dist/index.js",
"bin": {
"opencode-multi-model": "dist/cli.js"
},
"exports": {
".": "./dist/index.js",
"./core": "./dist/core/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "bun build src/index.ts src/cli.ts --outdir dist --target bun --format esm",
"test": "bun test",
"coverage": "bun test --coverage",
"prepublishOnly": "bun run build",
"release": "bash ./scripts/release.sh",
"setup": "bash ./scripts/setup.sh",
"lint": "biome check .",
"lint:fix": "biome check . --fix",
"format": "biome format . --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chalk": "^5.3.0",
"@opencode-ai/plugin": "^1.2.27",
"commander": "^12.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/bun": "^1.3.10",
"@types/node": "^25.5.0",
"typescript": "^5.9.3"
},
"keywords": [
"opencode",
"multi-model",
"tmux",
"ai",
"plugin"
],
"license": "MIT"
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -e
# Prompt for version bump type
read -p "Select version bump (major/minor/patch): " bump
if [[ ! "$bump" =~ ^(major|minor|patch)$ ]]; then
echo "Invalid choice. Must be 'major', 'minor', or 'patch'."
exit 1
fi
# Run tests
echo "Running test suite..."
npm test
# Build the project
echo "Building the project..."
npm run build
# Bump version
echo "Bumping version ($bump)..."
npm version "$bump" -m "chore(release): %s"
# Show git status
git status
# Confirm publishing
read -p "Proceed with npm publish? (y/N): " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Publish aborted."
exit 0
fi
# Publish to npm
npm publish
# Push git commits and tags
git push && git push --tags
echo "Release completed successfully."
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Setup script for development environment
echo "Setting up git hooks..."
git config core.hooksPath .githooks
echo "Setup complete!"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { cleanupMultiModel } from "./core/cleanup";
import { closeMultiModel } from "./core/close";
import { openMultiModel } from "./core/open";
import { getBinaryName } from "./core/utils";
const program = new Command();
program
.name("opencode-multi-model")
.description("Launch multiple OpenCode models in tmux sessions")
.version("1.0.0");
program
.command("open")
.description("Create a new multi-model tmux session")
.argument("<session-name>", "Name for the tmux session")
.option(
"-m, --models <models...>",
"Model IDs to launch (space-separated)",
[],
)
.option(
"-b, --binary <binary>",
"Binary to use (opencode or kilo). Defaults to env var OPENCODE_MULTI_MODEL_BINARY or 'opencode'",
)
.action(
async (
sessionName: string,
options: { models: string[]; binary?: string },
) => {
try {
const binaryName = options.binary || getBinaryName();
const result = await openMultiModel({
sessionName,
models: options.models,
binaryName,
mode: "cli",
});
if (result.success) {
console.log(
result.instructions || `Created session: ${result.sessionName}`,
);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
},
);
program
.command("close")
.description("Close a multi-model tmux session")
.argument("<session-name>", "Name of the tmux session to close")
.option(
"--keep-worktrees",
"Do not remove worktrees and delete branches",
false,
)
.option("--no-tags", "Create archive tags before deleting branches")
.option("-f, --force", "Skip confirmation prompts", false)
.action(
async (
sessionName: string,
options: { keepWorktrees: boolean; force: boolean; tags: boolean },
) => {
try {
const result = await closeMultiModel({
sessionName,
cleanupWorktrees: !options.keepWorktrees,
force: options.force,
createArchiveTags: options.tags,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
},
);
program
.command("cleanup")
.description("Delete all archive tags from local and remote")
.option("-f, --force", "Skip confirmation prompts", false)
.option(
"-r, --remote <remote>",
"Remote name to push deletions to (default: first remote from git remote -v)",
)
.option("--no-remote", "Only delete local tags, skip remote")
.action(async (options: { force: boolean; remote?: string | boolean }) => {
try {
const result = await cleanupMultiModel({
force: options.force,
remote: options.remote === true ? undefined : options.remote,
});
if (result.success) {
console.log(result.instructions);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
} catch (error) {
console.error(`Error: ${error}`);
process.exit(1);
}
});
program.parse();
+220
View File
@@ -0,0 +1,220 @@
import * as readline from "node:readline";
import chalk from "chalk";
import type { CleanupOptions, CleanupResult } from "../types";
import { runCommand } from "./utils";
/**
* Prompts the user for confirmation on the terminal.
*/
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);
});
});
}
/**
* Lists all archive tags matching the pattern archive/*.
*/
export async function listArchiveTags(): Promise<string[]> {
const { stdout } = await runCommand(["git", "tag", "-l", "archive/*"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Gets the list of available git remotes.
*/
export async function getAvailableRemotes(): Promise<string[]> {
const { stdout } = await runCommand(["git", "remote"]);
return stdout.split("\n").filter(Boolean);
}
/**
* Deletes a local git tag.
*/
async function deleteLocalTag(tagName: string): Promise<boolean> {
const result = await runCommand(["git", "tag", "-d", tagName]);
return result.ok;
}
/**
* Deletes a tag from a remote repository.
*/
async function deleteRemoteTag(
tagName: string,
remoteName: string,
): Promise<{ ok: boolean; error: string }> {
const result = await runCommand([
"git",
"push",
remoteName,
"--delete",
tagName,
]);
return {
ok: result.ok,
error: result.ok ? "" : result.stderr,
};
}
/**
* Cleans up archive tags from local and optionally remote repositories.
*
* Archive tags have the format: archive/<branch-name>-<timestamp>
* These are created by the close command to preserve branch state before deletion.
*
* @param options Cleanup configuration including force mode and remote settings.
* @returns Result with counts and details of deleted tags.
*/
export async function cleanupMultiModel(
options: CleanupOptions = {},
): Promise<CleanupResult> {
const { force = false, remote } = options;
// Get available remotes and archive tags first
const availableRemotes = await getAvailableRemotes();
const archiveTags = await listArchiveTags();
// Determine whether to push to remote and which remote to use:
// - remote === false: --no-remote flag, local-only mode
// - remote === string: use specified remote (error if doesn't exist)
// - remote === undefined: use first available remote
const shouldPushToRemote = remote !== false;
let remoteToDeleteFrom: string | undefined;
if (shouldPushToRemote) {
if (typeof remote === "string") {
// User specified a remote - validate it exists
if (!availableRemotes.includes(remote)) {
return {
success: false,
error: `Error: Remote '${remote}' not found. Available remotes: ${availableRemotes.join(", ") || "none"}`,
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
remoteToDeleteFrom = remote;
} else {
// remote === undefined, use first available
remoteToDeleteFrom = availableRemotes[0];
}
}
if (archiveTags.length === 0) {
return {
success: true,
instructions: chalk.dim("No archive tags found."),
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
// Confirmation prompt (unless --force)
if (!force) {
const tagCount = archiveTags.length;
const tagWord = tagCount === 1 ? "tag" : "tags";
console.log(chalk.yellow(`Found ${tagCount} archive ${tagWord}.`));
if (shouldPushToRemote && remoteToDeleteFrom) {
console.log(
chalk.dim(`Remote deletions will use: ${remoteToDeleteFrom}`),
);
} else if (shouldPushToRemote && !remoteToDeleteFrom) {
console.log(
chalk.dim(
"No remotes configured. Only local cleanup will be performed.",
),
);
}
const answer = await promptUser(
chalk.bold("\nProceed with cleanup? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
error: "Cleanup cancelled by user",
localTagsDeleted: 0,
remoteTagsDeleted: 0,
localTagNames: [],
remoteTagNames: [],
remoteTagErrors: [],
availableRemotes,
};
}
}
const localTagNames: string[] = [];
const remoteTagNames: string[] = [];
const remoteTagErrors: Array<{ tag: string; error: string }> = [];
// Delete local tags
for (const tag of archiveTags) {
const deleted = await deleteLocalTag(tag);
if (deleted) {
localTagNames.push(tag);
}
}
// Delete from remote (if remote is specified and exists)
if (shouldPushToRemote && remoteToDeleteFrom) {
for (const tag of localTagNames) {
const result = await deleteRemoteTag(tag, remoteToDeleteFrom);
if (result.ok) {
remoteTagNames.push(tag);
} else {
// Continue with local cleanup even if remote fails
remoteTagErrors.push({ tag, error: result.error });
}
}
}
// Build instructions message
const instructionsArr: string[] = [];
if (localTagNames.length > 0) {
const word = localTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${localTagNames.length} local archive ${word}.`),
);
}
if (remoteTagNames.length > 0) {
const word = remoteTagNames.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.green(`Deleted ${remoteTagNames.length} remote archive ${word}.`),
);
}
if (remoteTagErrors.length > 0) {
const word = remoteTagErrors.length === 1 ? "tag" : "tags";
instructionsArr.push(
chalk.yellow(
`Failed to delete ${remoteTagErrors.length} remote ${word} (local cleanup continued).`,
),
);
}
return {
success: true,
instructions: instructionsArr.join("\n"),
localTagsDeleted: localTagNames.length,
remoteTagsDeleted: remoteTagNames.length,
localTagNames,
remoteTagNames,
remoteTagErrors,
availableRemotes,
};
}
+191
View File
@@ -0,0 +1,191 @@
import * as readline from "node:readline";
import chalk from "chalk"; // removed console usage
import type { CloseSessionOptions, CloseSessionResult } from "../types";
import {
getSessionPath,
getWorktreesForSession,
runCommand,
sanitizeName,
} from "./utils";
/**
* Prompts the user for input on the terminal.
*
* @param question The question to display.
* @returns The user's answer.
*/
async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* Closes a multi-model tmux session and optionally cleans up worktrees and branches.
*
* When `cleanupWorktrees` is true, removes git worktrees, creates archive tags
* for recovery, and deletes the associated branches.
*
* @param options Close configuration including session name and cleanup flags.
* @returns Result with details about what was cleaned up.
*
* @example
* ```ts
* const result = await closeMultiModel({
* sessionName: "compare",
* cleanupWorktrees: true,
* force: true,
* });
* if (result.success) {
* console.log(`Closed ${result.sessionName}, removed ${result.worktreesRemoved?.length} worktrees`);
* }
* ```
*/
export async function closeMultiModel(
options: CloseSessionOptions,
): Promise<CloseSessionResult> {
const worktreesRemoved: string[] = [];
const instructionsArr: string[] = [];
const branchesDeleted: string[] = [];
const tagsCreated: string[] = [];
const safeSessionName = sanitizeName(options.sessionName);
try {
// Check if session exists
const { ok: sessionExists } = await runCommand([
"tmux",
"has-session",
"-t",
options.sessionName,
]);
// Kill tmux session if it exists
if (sessionExists) {
await runCommand(["tmux", "kill-session", "-t", options.sessionName]);
instructionsArr.push(
chalk.green(`Closed session '${options.sessionName}'`),
);
} else {
instructionsArr.push(
chalk.yellow(`Session '${options.sessionName}' does not exist`),
);
}
// Get list of worktrees for this session before killing tmux
const worktrees = await getWorktreesForSession(safeSessionName);
// If cleanup requested and not forced, ask for confirmation
if (options.cleanupWorktrees && !options.force && worktrees.length > 0) {
console.log(
chalk.red("The following worktrees and branches will be removed:"),
);
worktrees.forEach((wt) => {
const msg = ` - ${wt.path} (branch: ${wt.branch})`;
console.log(msg.trim());
});
const answer = await promptUser(
chalk.bold("\nDo you want to proceed? (y/N): "),
);
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
return {
success: false,
sessionName: safeSessionName,
error: "Cleanup cancelled by user",
instructions: instructionsArr.join("\n"),
};
}
}
let cleanupPerformed = false;
// Cleanup worktrees and optionally branches
if (options.cleanupWorktrees) {
for (const worktree of worktrees) {
try {
// Remove worktree
await runCommand(["git", "worktree", "remove", "-f", worktree.path]);
worktreesRemoved.push(worktree.path);
// Optionally create an archive tag before deleting the branch for safe recovery
if (options.createArchiveTags !== false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const archiveTag = `archive/${worktree.branch}-${timestamp}`;
const tagResult = await runCommand([
"git",
"tag",
archiveTag,
worktree.branch,
]);
if (tagResult.ok) {
tagsCreated.push(archiveTag);
} else {
const warningMsg = chalk.red(
`Warning: Failed to create tag ${archiveTag} for branch ${worktree.branch}.`,
);
instructionsArr.push(warningMsg);
}
}
await runCommand(["git", "branch", "-D", worktree.branch]);
branchesDeleted.push(worktree.branch);
} catch (err) {
instructionsArr.push(
chalk.red(`Failed to cleanup worktree ${worktree.path}: ${err}`),
);
}
}
// Also remove the base directory
const worktreeBase = getSessionPath(safeSessionName);
try {
await runCommand(["rm", "-rf", worktreeBase]);
} catch {
// Ignore errors
}
cleanupPerformed = worktreesRemoved.length > 0;
if (cleanupPerformed) {
instructionsArr.push(
chalk.dim(
`Removed ${worktreesRemoved.length} worktrees, alongwith their branches.`,
),
);
if (tagsCreated.length > 0) {
instructionsArr.push(
chalk.dim(`Created archive tags: ${tagsCreated.join(", ")}.`),
);
}
}
}
return {
success: true,
sessionName: safeSessionName,
cleanupPerformed,
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
} catch (error) {
return {
success: false,
sessionName: safeSessionName,
error: String(error),
worktreesRemoved,
branchesDeleted,
tagsCreated,
instructions: instructionsArr.join("\n"),
};
}
}
+4
View File
@@ -0,0 +1,4 @@
export { cleanupMultiModel } from "./cleanup";
export { closeMultiModel } from "./close";
export { openMultiModel } from "./open";
export * from "./utils";
+324
View File
@@ -0,0 +1,324 @@
import * as fs from "node:fs";
import chalk from "chalk";
import type { MultiModelOptions, MultiModelResult } from "../types";
import {
createWindowPlans,
findDuplicates,
formatInvalidModelError,
getWorktreePath,
launchModelInWindow,
normalizeModels,
runCommand,
sanitizeName,
undoWorktree,
} from "./utils";
/**
* Launches multiple AI models in a tmux session with git worktrees.
*
* Creates a tmux session with one window per model, each in its own git worktree.
* Validates models against `opencode models` output before launching.
*
* @param options Launch configuration including session name and model list.
* @returns Result with success status, window names, and usage instructions.
*
* @example
* ```ts
* const result = await openMultiModel({
* sessionName: "compare",
* models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
* binaryName: "opencode",
* });
* if (result.success) {
* console.log(result.instructions);
* }
* ```
*/
export async function openMultiModel(
options: MultiModelOptions,
): Promise<MultiModelResult> {
const sessionName = options.sessionName.trim();
const safeSessionName = sanitizeName(sessionName);
const models = normalizeModels(options.models);
const binaryName = options.binaryName || "opencode";
const mode = options.mode || "cli";
if (!sessionName) {
return {
success: false,
sessionName: "",
error: "Error: `sessionName` must not be empty.",
};
}
if (models.length === 0) {
return {
success: false,
sessionName,
error: "Error: Model names must not be empty.",
};
}
const duplicateModels = findDuplicates(models);
if (duplicateModels.length > 0) {
return {
success: false,
sessionName,
error: `Error: Duplicate model names are not allowed: ${duplicateModels.map((item) => `'${item}'`).join(", ")}.`,
};
}
const gitRepoCheck = await runCommand([
"git",
"rev-parse",
"--is-inside-work-tree",
]);
if (!gitRepoCheck.ok) {
return {
success: false,
sessionName,
error: "Error: `multi-model` requires a git repository.",
};
}
const tmuxExists = await runCommand(["command", "-v", "tmux"]);
if (!tmuxExists.ok) {
return {
success: false,
sessionName,
error: "Error: `tmux` is not installed or not on `PATH`.",
};
}
const binaryExists = await runCommand(["command", "-v", binaryName]);
if (!binaryExists.ok) {
return {
success: false,
sessionName,
error: `Error: \`${binaryName}\` is not installed or not on \`PATH\`.`,
};
}
const modelListResult = await runCommand([binaryName, "models"]);
if (!modelListResult.ok) {
return {
success: false,
sessionName,
error: `Error: Failed to load valid models from \`${binaryName} models\`${modelListResult.stderr ? `: ${modelListResult.stderr}` : "."}`,
};
}
const allowlist = modelListResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const invalidModels = models.filter((model) => !allowlist.includes(model));
if (invalidModels.length > 0) {
return {
success: false,
sessionName,
error: formatInvalidModelError(invalidModels, allowlist),
};
}
const sessionExists = await runCommand([
"tmux",
"has-session",
"-t",
sessionName,
]);
if (sessionExists.ok) {
return {
success: false,
sessionName,
error: "Error: Session already exists. Use a different `sessionName`.",
};
}
const windowPlans = createWindowPlans(models);
const succeededModels: string[] = [];
const failedModels: string[] = [];
for (let i = 0; i < windowPlans.length; i++) {
const plan = windowPlans[i]!;
const isFirst = i === 0;
const worktreePath = getWorktreePath(safeSessionName, plan.windowName);
const branchName = `opencode/${safeSessionName}/${plan.windowName}`;
if (fs.existsSync(worktreePath)) {
if (!isFirst) {
failedModels.push(
`${plan.model} (Worktree path already exists at ${worktreePath})`,
);
continue;
} else {
return {
success: false,
sessionName,
error: `Error: Worktree path already exists at ${worktreePath}. Clean it up first.`,
};
}
}
const branchExists = await runCommand([
"git",
"show-ref",
"--verify",
"--quiet",
`refs/heads/${branchName}`,
]);
if (branchExists.ok) {
if (!isFirst) {
failedModels.push(
`${plan.model} (Branch '${branchName}' already exists)`,
);
continue;
} else {
return {
success: false,
sessionName,
error: `Error: Branch '${branchName}' already exists.`,
};
}
}
const worktreeResult = await runCommand([
"git",
"worktree",
"add",
"-b",
branchName,
worktreePath,
]);
if (!worktreeResult.ok) {
if (!isFirst) {
failedModels.push(
`${plan.model} (Failed to create worktree: ${worktreeResult.stderr})`,
);
continue;
} else {
return {
success: false,
sessionName,
error: `Error: Failed to create worktree for ${plan.model}: ${worktreeResult.stderr}`,
};
}
}
if (isFirst) {
const sessionCreateResult = await runCommand([
"tmux",
"new-session",
"-d",
"-s",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!sessionCreateResult.ok) {
const undoErrors = await undoWorktree(worktreePath, branchName);
let errorMsg = `Error: Failed to create tmux session '${sessionName}'.${sessionCreateResult.stderr ? ` ${sessionCreateResult.stderr}` : ""}`;
if (undoErrors.length > 0) {
errorMsg += ` Cleanup errors: ${undoErrors.join(", ")}`;
}
return { success: false, sessionName, error: errorMsg };
}
} else {
const windowCreateResult = await runCommand([
"tmux",
"new-window",
"-d",
"-t",
sessionName,
"-n",
plan.windowName,
"-c",
worktreePath,
]);
if (!windowCreateResult.ok) {
failedModels.push(
`${plan.model} (${windowCreateResult.stderr || "failed to create window"})`,
);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0) {
failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
}
continue;
}
}
const launchResult = await launchModelInWindow(
sessionName,
plan,
binaryName,
);
if (launchResult.ok) {
succeededModels.push(plan.model);
} else {
failedModels.push(
`${plan.model} (${launchResult.stderr || "failed to send launch command"})`,
);
const undoErrors = await undoWorktree(worktreePath, branchName);
if (undoErrors.length > 0)
failedModels.push(
`cleanup failed for ${plan.model}: ${undoErrors.join(", ")}`,
);
}
}
const windowNames = windowPlans.map((p) => p.windowName);
const attachCommand = `tmux attach -t ${sessionName}`;
const cleanupCommand = `tmux kill-session -t ${sessionName} && rm -rf ~/.local/share/opencode/multi-model/${safeSessionName} && git worktree prune && git branch -D $(git branch --format='%(refname:short)' --list 'opencode/${safeSessionName}/*')`;
const primaryCleanup =
mode === "tool"
? `call tool multi-model-close with sessionName: ${sessionName}`
: `opencode-multi-model close ${sessionName}`;
if (failedModels.length > 0) {
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
chalk.red(
`Error: Created tmux session '${sessionName}', but some model launches failed.`,
),
chalk.red(`Failed: ${failedModels.join(", ")}.`),
chalk.dim(
`Succeeded: ${succeededModels.length > 0 ? succeededModels.join(", ") : "none"}.`,
),
"",
chalk.bold(`Attach with \`${attachCommand}\` to inspect the session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand),
].join("\n"),
};
}
return {
success: true,
sessionName,
windows: windowNames,
instructions: [
chalk.bold(`Use \`${attachCommand}\` to join session.`),
"",
chalk.dim(`When finished, clean up using:`),
primaryCleanup,
"",
chalk.dim(`Alternate manual cleanup - `),
chalk.dim(cleanupCommand),
].join("\n"),
};
}
+455
View File
@@ -0,0 +1,455 @@
import * as os from "node:os";
import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24;
/**
* Runs a command and captures its output without throwing on non-zero exit codes.
*
* @param parts Command segments to pass to the shell.
* @returns The exit status plus captured stdout and stderr.
*
* @example
* ```ts
* const result = await runCommand(["command", "-v", "tmux"]);
* if (!result.ok) {
* return "Error: `tmux` is not installed.";
* }
* ```
*/
export async function runCommand(parts: string[]): Promise<CommandResult> {
const result = await Bun.$`${parts}`.quiet().nothrow();
return {
ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
/**
* Escapes a value for safe use inside a shell command string.
*
* @param value Raw user-provided value.
* @returns A POSIX-safe single-quoted string.
*
* @example
* ```ts
* const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`;
* ```
*/
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* Normalizes requested model ids by trimming whitespace and dropping empty items.
*
* @param models Raw tool input.
* @returns Clean model ids in the original order.
*
* @example
* ```ts
* const normalized = normalizeModels([" openai/gpt-5.4 ", ""]);
* // ["openai/gpt-5.4"]
* ```
*/
export function normalizeModels(models: string[] | undefined): string[] {
return (models ?? []).map((model) => model.trim()).filter(Boolean);
}
/**
* Finds duplicate values while preserving their first repeated occurrence order.
*
* @param values Values to inspect.
* @returns Duplicate entries exactly once each.
*
* @example
* ```ts
* const duplicates = findDuplicates(["a", "b", "a", "b"]);
* // ["a", "b"]
* ```
*/
export function findDuplicates(values: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
continue;
}
seen.add(value);
}
return [...duplicates];
}
/**
* Builds a short tmux-safe window label from a model id.
*
* @param model Full model id.
* @returns A concise window label.
*
* @example
* ```ts
* const label = createWindowBaseName("openai/gpt-5.4");
* // "gpt-5-4"
* ```
*/
export function createWindowBaseName(model: string): string {
const preferredPart = model.split("/").at(-1) ?? model;
const sanitized = preferredPart
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, WINDOW_NAME_LIMIT);
return sanitized || "model";
}
/**
* Makes window names unique when sanitized model labels collide.
*
* @param models Validated model ids.
* @returns Window plans containing the full model id and unique tmux window name.
*
* @example
* ```ts
* const plans = createWindowPlans(["provider/a", "other/a"]);
* // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }]
* ```
*/
export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
const counts = new Map<string, number>();
return models.map((model) => {
const baseName = createWindowBaseName(model);
const nextCount = (counts.get(baseName) ?? 0) + 1;
counts.set(baseName, nextCount);
if (nextCount === 1) {
return { model, windowName: baseName };
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(
0,
Math.max(1, WINDOW_NAME_LIMIT - suffix.length),
);
return {
model,
windowName: `${trimmedBase}${suffix}`,
};
});
}
/**
* Computes Levenshtein distance for fuzzy model suggestions.
*
* @param left First string.
* @param right Second string.
* @returns Edit distance between the two strings.
*
* @example
* ```ts
* const distance = levenshtein("gpt5.4", "gpt-5.4");
* // 1
* ```
*/
export function levenshtein(left: string, right: string): number {
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
let previous = row[0];
row[0] = leftIndex;
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const current = row[rightIndex];
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
row[rightIndex] = Math.min(
row[rightIndex]! + 1,
row[rightIndex - 1]! + 1,
previous! + cost,
);
previous = current;
}
}
return row[right.length]!;
}
/**
* Suggests close model ids for invalid input.
*
* @param requested Invalid requested model id.
* @param allowlist Known valid model ids.
* @returns Up to three likely matches ordered by relevance.
*
* @example
* ```ts
* const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]);
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
export function suggestModels(
requested: string,
allowlist: string[],
): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
.map((candidate) => {
const normalizedCandidate = candidate.toLowerCase();
const distance = levenshtein(normalizedRequested, normalizedCandidate);
const containsBoost =
normalizedCandidate.includes(normalizedRequested) ||
normalizedRequested.includes(normalizedCandidate)
? -2
: 0;
return {
candidate,
score: distance + containsBoost,
};
})
.sort(
(left, right) =>
left.score - right.score ||
left.candidate.localeCompare(right.candidate),
)
.slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate);
}
/**
* Formats invalid model errors with repair hints.
*
* @param invalidModels Invalid requested model ids.
* @param allowlist Known valid model ids.
* @returns A user-facing error string.
*
* @example
* ```ts
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
* ```
*/
export function formatInvalidModelError(
invalidModels: string[],
allowlist: string[],
): string {
const firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText =
suggestions.length > 0
? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?`
: " Run `opencode models` and try again.";
if (invalidModels.length === 1) {
return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`;
}
return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`;
}
/**
* Launches an OpenCode command in a tmux window.
*
* @param sessionName Existing tmux session name.
* @param plan Window launch plan.
* @param binaryName Binary to launch ('opencode' or 'kilo').
* @returns Result describing whether the command was sent successfully.
*
* @example
* ```ts
* await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" });
* ```
*/
export async function launchModelInWindow(
sessionName: string,
plan: WindowLaunchPlan,
binaryName: string = "opencode",
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand([
"tmux",
"send-keys",
"-t",
`${sessionName}:${plan.windowName}`,
launchCommand,
"C-m",
]);
}
/**
* Sanitizes a session name to be safe for paths and branch names.
*
* @param name Raw session name.
* @returns Sanitized name safe for use in paths and git branches.
*
* @example
* ```ts
* sanitizeName("test session!");
* // "test-session"
* ```
*/
export function sanitizeName(name: string): string {
return name
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Generates the full absolute path for a session's base directory.
*
* @param sessionName Sanitized session name.
* @returns Absolute path to the session directory.
*
* @example
* ```ts
* getSessionPath("my-session");
* // "/home/user/.local/share/opencode/multi-model/my-session"
* ```
*/
export function getSessionPath(sessionName: string): string {
const homedir = os.homedir();
return path.join(
homedir,
".local",
"share",
"opencode",
"multi-model",
sessionName,
);
}
/**
* Generates the full absolute path for a worktree.
*
* @param safeSession Sanitized session name.
* @param windowName Tmux window name.
* @returns Absolute path to the worktree directory.
*
* @example
* ```ts
* getWorktreePath("my-session", "gpt-5-2");
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
* ```
*/
export function getWorktreePath(
safeSession: string,
windowName: string,
): string {
return path.join(getSessionPath(safeSession), windowName);
}
/**
* Retrieves all git worktrees associated with a session.
*
* @param sessionName Sanitized session name.
* @returns Array of worktree info objects filtered to the session.
*
* @example
* ```ts
* const worktrees = await getWorktreesForSession("my-session");
* ```
*/
export async function getWorktreesForSession(
sessionName: string,
): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand([
"git",
"worktree",
"list",
"--porcelain",
]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {};
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
currentWorktree = {
path: line.slice(9),
};
} else if (line.startsWith("branch ")) {
currentWorktree.branch = line.slice(7).replace(/^refs\/heads\//, "");
}
}
// Add last worktree
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
// Filter for session-specific worktrees
return worktrees.filter((wt) => wt.path.startsWith(sessionPath));
}
/**
* Removes a git worktree and deletes its branch on failure.
*
* @param worktreePath Absolute path to the worktree.
* @param branchName Branch name to delete.
* @returns Array of error messages (empty if successful).
*
* @example
* ```ts
* const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
* if (errors.length > 0) console.error(errors);
* ```
*/
export async function undoWorktree(
worktreePath: string,
branchName: string,
): Promise<string[]> {
const errors: string[] = [];
const removeRes = await runCommand([
"git",
"worktree",
"remove",
"-f",
worktreePath,
]);
if (!removeRes.ok)
errors.push(
`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`,
);
const branchRes = await runCommand(["git", "branch", "-D", branchName]);
if (!branchRes.ok)
errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
return errors;
}
/**
* Determines the binary name to use based on environment variable or default.
*
* Priority: env var OPENCODE_MULTI_MODEL_BINARY > default ("opencode").
*
* @returns Binary name string.
*
* @example
* ```ts
* // With OPENCODE_MULTI_MODEL_BINARY=kilo
* getBinaryName(); // "kilo"
*
* // Without env var
* getBinaryName(); // "opencode"
* ```
*/
export function getBinaryName(): string {
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode";
}
+35
View File
@@ -0,0 +1,35 @@
import type { Plugin } from "@opencode-ai/plugin";
import { cleanupTool } from "./tools/cleanup";
import { closeTool } from "./tools/close";
import { openTool } from "./tools/open";
/**
* OpenCode plugin that provides multi-model tmux session management tools.
*
* Registers three tools:
* - `multi-model-open`: Launch multiple AI models in a tmux session
* - `multi-model-close`: Close a multi-model session and cleanup resources
* - `multi-model-cleanup`: Delete all archive tags from local and remote repositories
*
* @example
* ```json
* // opencode.json
* {
* "plugin": ["@username/opencode-multi-model"]
* }
* ```
*/
const OpenCodeMultiModelPlugin: Plugin = async (_ctx) => {
return {
tool: {
"multi-model-open": openTool,
"multi-model-close": closeTool,
"multi-model-cleanup": cleanupTool,
},
};
};
export default OpenCodeMultiModelPlugin;
export * from "./core/index";
export * from "./types";
export { cleanupTool, closeTool, openTool };
+45
View File
@@ -0,0 +1,45 @@
import { tool } from "@opencode-ai/plugin";
import { cleanupMultiModel } from "../core/cleanup";
/**
* OpenCode tool definition for cleaning up archive tags.
*
* Deletes archive tags (format: archive/*) from local and remote repositories.
* Used to clean up recovery tags created by the close command.
*/
export const cleanupTool = tool({
description:
"Delete all archive tags from local and optionally remote repository",
args: {
remote: tool.schema
.union([
tool.schema
.string()
.describe(
"Remote name to push deletions to. Error if doesn't exist.",
),
tool.schema
.literal(false)
.describe("Only delete local tags, don't push to remote."),
])
.optional()
.describe(
"Remote handling: string=specific remote, false=local-only, undefined=first available remote",
),
},
async execute(args, _context) {
// In plugin mode, we skip confirmation (force=true)
const result = await cleanupMultiModel({
force: true,
remote: args.remote,
});
if (!result.success) {
return result.error!;
}
return (
result.instructions ?? `Deleted ${result.localTagsDeleted} local tags.`
);
},
});
+46
View File
@@ -0,0 +1,46 @@
import { tool } from "@opencode-ai/plugin";
import { closeMultiModel } from "../core/close";
/**
* OpenCode tool definition for closing multi-model tmux sessions.
*
* Kills the tmux session and optionally removes git worktrees,
* creates archive tags, and deletes associated branches.
*/
export const closeTool = tool({
description:
"Close a multi-model tmux session and optionally cleanup worktrees and branches",
args: {
sessionName: tool.schema
.string()
.min(1)
.describe("tmux session name to close"),
cleanupWorktrees: tool.schema
.boolean()
.default(true)
.describe(
"whether to remove worktrees and delete branches (default: true)",
),
createArchiveTags: tool.schema
.boolean()
.default(true)
.describe(
"whether to create archive tags before deleting branches (default: true)",
),
},
async execute(args, _context) {
// In plugin mode, we skip confirmation (force=true) since there's no interactive terminal
const result = await closeMultiModel({
sessionName: args.sessionName,
cleanupWorktrees: args.cleanupWorktrees,
createArchiveTags: args.createArchiveTags,
force: true,
});
if (!result.success) {
return result.error!;
}
return result.instructions;
},
});
+50
View File
@@ -0,0 +1,50 @@
import { tool } from "@opencode-ai/plugin";
import { openMultiModel } from "../core/open";
import { getBinaryName } from "../core/utils";
/**
* OpenCode tool definition for launching multiple AI models in tmux sessions.
*
* Creates a tmux session with one window per model, each in its own git worktree.
* Returns instructions for attaching to and cleaning up the session.
*/
export const openTool = tool({
description: "Launch multiple OpenCode models in tmux",
args: {
sessionName: tool.schema
.string()
.min(1)
.describe("tmux session name to create"),
models: tool.schema
.array(tool.schema.string().min(1))
.min(1)
.describe("one or more OpenCode model ids to launch"),
},
async execute(args, context) {
const binaryName = getBinaryName();
const result = await openMultiModel({
sessionName: args.sessionName,
models: args.models,
binaryName,
mode: "tool",
});
context.metadata({
title: `multi-model ${args.sessionName}`,
metadata: {
safeSessionName: result.sessionName,
modelCount: args.models.length,
},
});
if (!result.success) {
return result.error!;
}
return (
result.instructions ||
`Created session "${result.sessionName}" with ${result.windows?.length || 0} windows`
);
},
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Options for launching a multi-model tmux session.
*/
export interface MultiModelOptions {
/** Name for the tmux session. */
sessionName: string;
/** List of model ids to launch. */
models: string[];
/** Binary name to use ('opencode' or 'kilo'). Defaults to 'opencode'. */
binaryName?: string;
/** Caller context: 'cli' shows shell commands, 'tool' shows tool instructions. */
mode?: "cli" | "tool";
}
/**
* Result returned after attempting to launch a multi-model session.
*/
export interface MultiModelResult {
/** Whether the launch succeeded. */
success: boolean;
/** The session name used. */
sessionName: string;
/** Window names created in the tmux session. */
windows?: string[];
/** Error message if the launch failed. */
error?: string;
/** Instructions for attaching and cleaning up the session. */
instructions?: string;
}
/**
* Options for closing a multi-model tmux session.
*/
export interface CloseSessionOptions {
/** Whether to create archive tags before deleting branches. */
createArchiveTags?: boolean;
/** Name of the tmux session to close. */
sessionName: string;
/** Whether to remove worktrees and delete branches. */
cleanupWorktrees?: boolean;
/** Skip confirmation prompts. */
force?: boolean;
}
/**
* Result returned after attempting to close a multi-model session.
*/
export interface CloseSessionResult {
/** Console messages and instructions. */
instructions: string;
/** Whether the close succeeded. */
success: boolean;
/** The session name that was closed. */
sessionName: string;
/** Error message if the close failed. */
error?: string;
/** Whether cleanup was performed. */
cleanupPerformed?: boolean;
/** Worktree paths that were removed. */
worktreesRemoved?: string[];
/** Branch names that were deleted. */
branchesDeleted?: string[];
/** Archive tags that were created before branch deletion. */
tagsCreated?: string[];
}
/**
* Internal type for a window launch plan.
*/
export interface WindowLaunchPlan {
/** Full model id. */
model: string;
/** Unique tmux window name. */
windowName: string;
}
/**
* Options for cleaning up archive tags.
*/
export interface CleanupOptions {
/** Skip confirmation prompts. */
force?: boolean;
/**
* Remote name to push deletions to.
* - If string: use specified remote (error if doesn't exist)
* - If undefined: use first available remote from git remote -v
* - If false: only local cleanup (--no-remote flag)
*/
remote?: string | false;
}
/**
* Result returned after attempting to cleanup archive tags.
*/
export interface CleanupResult {
/** Whether the cleanup succeeded. */
success: boolean;
/** Error message if the cleanup failed. */
error?: string;
/** Summary message for the user. */
instructions?: string;
/** Number of local tags deleted. */
localTagsDeleted: number;
/** Number of remote tags deleted. */
remoteTagsDeleted: number;
/** Names of tags that were deleted locally. */
localTagNames: string[];
/** Names of tags deleted from remote. */
remoteTagNames: string[];
/** Tags that failed remote deletion. */
remoteTagErrors: Array<{ tag: string; error: string }>;
/** Available remotes discovered. */
availableRemotes: 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;
}
+406
View File
@@ -0,0 +1,406 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as readline from "node:readline";
import { cleanupMultiModel } from "../src/core/cleanup";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("cleanupMultiModel", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("no archive tags found - returns success with message", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
expect(result.instructions).toContain("No archive tags found");
});
test("user cancels confirmation - returns success=false", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
expect(result.localTagsDeleted).toBe(0);
expect(result.remoteTagsDeleted).toBe(0);
});
test("force mode skips confirmation", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
});
test("--no-remote flag (remote=false) - only local cleanup, no remote push", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("remote deletion failure continues local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: false,
stdout: "",
stderr: "remote error",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(2);
expect(result.remoteTagsDeleted).toBe(1);
expect(result.remoteTagErrors.length).toBe(1);
expect(result.remoteTagErrors[0]?.tag).toBe("archive/test-1");
});
test("dynamic remote detection - remote=undefined uses first available remote", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true, remote: undefined });
expect(result.success).toBe(true);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote name validation - uses specified remote if it exists", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "upstream\norigin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push upstream --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "upstream",
});
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual(["upstream", "origin"]);
expect(executedCommands).toContain(
"git push upstream --delete archive/test-1",
);
});
test("remote error - returns error if specified remote doesn't exist", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
const result = await cleanupMultiModel({
force: true,
remote: "nonexistent",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Remote 'nonexistent' not found");
expect(result.error).toContain("Available remotes: origin");
});
test("no remotes configured - only local cleanup", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.availableRemotes).toEqual([]);
expect(result.remoteTagsDeleted).toBe(0);
expect(executedCommands.some((c) => c.startsWith("git push"))).toBe(false);
});
test("user confirms cleanup when not forced - proceeds with deletion", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await cleanupMultiModel({ force: false });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(1);
expect(result.remoteTagsDeleted).toBe(1);
expect(executedCommands).toContain("git tag -d archive/test-1");
expect(executedCommands).toContain(
"git push origin --delete archive/test-1",
);
});
test("multiple archive tags deleted", async () => {
mockCommandResponses["git remote"] = {
ok: true,
stdout: "origin",
stderr: "",
};
mockCommandResponses["git tag -l archive/*"] = {
ok: true,
stdout: "archive/test-1\narchive/test-2\narchive/test-3",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git tag -d archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-1"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-2"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git push origin --delete archive/test-3"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await cleanupMultiModel({ force: true });
expect(result.success).toBe(true);
expect(result.localTagsDeleted).toBe(3);
expect(result.remoteTagsDeleted).toBe(3);
expect(result.localTagNames).toEqual([
"archive/test-1",
"archive/test-2",
"archive/test-3",
]);
});
});
+484
View File
@@ -0,0 +1,484 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import * as readline from "node:readline";
import { closeMultiModel } from "../src/core/close";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
test("kills existing session without cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["tmux kill-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
expect(executedCommands).toContain("tmux kill-session -t test-session");
});
test("no session exists, no cleanup", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(false);
expect(result.worktreesRemoved).toEqual([]);
expect(executedCommands).toContain("tmux has-session -t test-session");
// No kill-session should be issued
expect(
executedCommands.some((c) => c.startsWith("tmux kill-session")),
).toBe(false);
});
test("cleanup works with worktrees and force, all succeeds", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
// Tag command will default to ok:true via fallback
mockCommandResponses[
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Ensure removal commands were executed
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/test-session/model`,
);
});
test("user cancels cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to return "n"
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("n"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
expect(result.success).toBe(false);
expect(result.error).toBe("Cleanup cancelled by user");
});
test("user confirms cleanup when not forced", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
// Stub readline to simulate user confirming cleanup (answers "y")
const mockInterface = {
question: (_q: string, cb: (a: string) => void) => cb("y"),
close: () => {},
} as any;
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: false,
});
// Restore Date after invocation
global.Date = OriginalDate;
// Verify result indicates successful cleanup
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length).toBe(1);
// Verify the expected git commands were executed
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/test-session/model`,
);
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
expect(executedCommands).toContain(expectedTagCmd);
});
test("warning when tag creation fails", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
// Tag command will fail
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
mockCommandResponses[tagCommand] = {
ok: false,
stdout: "",
stderr: "tag error",
};
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
// Expect the warning message to be captured in instructions
expect(result.instructions.includes("Failed to create tag")).toBe(true);
expect(result.tagsCreated).toEqual([]);
});
test("cleanup works with unsanitized session name", async () => {
const unsanitized = "test session!";
const sanitized = "test-session"; // result of sanitizeName
// tmux lookup uses unsanitized name
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`
] = { ok: true, stdout: "", stderr: "" };
// Freeze timestamp for deterministic tag name
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
const OriginalDate = Date;
global.Date = class extends OriginalDate {
constructor() {
super();
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
return fixedDate;
}
override toISOString() {
return "2023-01-01T00:00:00.000Z";
}
} as any;
const result = await closeMultiModel({
sessionName: unsanitized,
cleanupWorktrees: true,
force: true,
});
// Restore Date
global.Date = OriginalDate;
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
expect(result.tagsCreated?.length).toBe(1);
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
expect(executedCommands).toContain(
`git worktree remove -f ${worktreePath}`,
);
expect(executedCommands).toContain(
`git branch -D opencode/${sanitized}/model`,
);
});
test("tmux commands use sessionName, not safeSessionName", async () => {
const unsanitized = "my session!";
const sanitized = "my-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = {
ok: true,
stdout: "",
stderr: "",
};
// No worktrees
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await closeMultiModel({
sessionName: unsanitized,
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(true);
// Verify tmux commands used unsanitized name
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
// Ensure sanitized name not used in tmux commands
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
});
test("skip archive tags when flag disabled", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: false,
stdout: "",
stderr: "",
};
const worktreePath =
"/home/user/.local/share/opencode/multi-model/test-session/model";
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: worktreeListOutput,
stderr: "",
};
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/test-session/model"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
] = { ok: true, stdout: "", stderr: "" };
const result = await closeMultiModel({
sessionName: "test-session",
cleanupWorktrees: true,
force: true,
createArchiveTags: false,
});
expect(result.success).toBe(true);
expect(result.cleanupPerformed).toBe(true);
expect(result.worktreesRemoved).toEqual([worktreePath]);
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
expect(result.tagsCreated?.length ?? 0).toBe(0);
// Ensure no tag command was executed
expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe(
false,
);
});
});
+72
View File
@@ -0,0 +1,72 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import { closeMultiModel } from "../src/core/close";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
// Simulate throwing error for a specific command
if (cmd === "git worktree list --porcelain") {
throw new Error("unexpected error");
}
const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("closeMultiModel unexpected error handling", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("catches thrown error and returns failure", async () => {
const result = await closeMultiModel({
sessionName: "any-session",
cleanupWorktrees: false,
force: false,
});
expect(result.success).toBe(false);
expect(result.error).toContain("unexpected error");
});
});
+526
View File
@@ -0,0 +1,526 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("multi-model launch", () => {
let existsSyncMock: ReturnType<typeof spyOn>;
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false);
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {
"git rev-parse --is-inside-work-tree": {
ok: true,
stdout: "true",
stderr: "",
},
"command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" },
"command -v opencode": {
ok: true,
stdout: "/usr/bin/opencode",
stderr: "",
},
"opencode models": {
ok: true,
stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet",
stderr: "",
},
"tmux has-session -t test-session": {
ok: false,
stdout: "",
stderr: "session not found",
},
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2":
{ ok: false, stdout: "", stderr: "" },
};
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails if session name is empty", async () => {
const result = await openMultiModel({
sessionName: "",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: `sessionName` must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if no models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: [],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Model names must not be empty.");
expect(executedCommands).toEqual([]);
});
test("fails if duplicate models are provided", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
expect(executedCommands).toEqual([]);
});
test("fails if not inside a git repository", async () => {
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: false,
stdout: "",
stderr: "not a git repo",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Error: `multi-model` requires a git repository.",
);
expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]);
});
test("fails if tmux is not installed", async () => {
mockCommandResponses["command -v tmux"] = {
ok: false,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Error: `tmux` is not installed or not on `PATH`.",
);
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
]);
});
test("fails if opencode is not installed", async () => {
mockCommandResponses["command -v opencode"] = {
ok: false,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Error: `opencode` is not installed or not on `PATH`.",
);
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
]);
});
test("fails if model name is not in allowlist", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["invalid/model"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Error: Model name 'invalid/model' not found",
);
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
]);
});
test("fails if session already exists", async () => {
mockCommandResponses["tmux has-session -t test-session"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Error: Session already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("successfully plans and executes tmux launch with single model", async () => {
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Use `tmux attach -t test-session` to join session",
);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith("tmux send-keys -t test-session:gpt-5-2"),
),
).toBe(true);
});
test("successfully launches multiple models", async () => {
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Use `tmux attach -t test-session` to join session",
);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"),
),
).toBe(true);
});
test("sanitizes session name", async () => {
mockCommandResponses["tmux has-session -t test session!"] = {
ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test session!",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Use `tmux attach -t test session!` to join session",
);
expect(
executedCommands.some((c) =>
c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"),
),
).toBe(true);
});
test("uses sessionName for tmux commands, not safeSessionName", async () => {
const unsanitized = "my awesome session!!";
const sanitized = "my-awesome-session";
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
ok: false,
stdout: "",
stderr: "",
};
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/opencode/${sanitized}/gpt-5-2`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[
`git worktree add -b opencode/${sanitized}/gpt-5-2 /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-session -d -s ${unsanitized} -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/${sanitized}/gpt-5-2`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${unsanitized}:gpt-5-2 opencode --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: unsanitized,
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(true);
// tmux commands should use unsanitized name
expect(
executedCommands.some((c) =>
c.startsWith(`tmux has-session -t ${unsanitized}`),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith(`tmux new-session -d -s ${unsanitized}`),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith(`tmux send-keys -t ${unsanitized}:`),
),
).toBe(true);
// sanitized name should NOT appear in tmux commands
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
false,
);
// git branches and paths should use sanitized name
expect(
executedCommands.some((c) =>
c.startsWith(`git worktree add -b opencode/${sanitized}/gpt-5-2`),
),
).toBe(true);
});
test("fails if tmux new-session fails for the first model and triggers undo", async () => {
mockCommandResponses[
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "tmux error" };
mockCommandResponses[
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create tmux session");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
"git branch -D opencode/test-session/gpt-5-2",
]);
});
test("fails if worktree path exists for first model", async () => {
existsSyncMock.mockImplementation((path: string) => {
return path.includes("test-session/gpt-5-2");
});
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Worktree path already exists");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
]);
});
test("fails if git branch exists for first model", async () => {
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain(
"Branch 'opencode/test-session/gpt-5-2' already exists",
);
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
]);
});
test("fails if git worktree add fails for first model", async () => {
mockCommandResponses[
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "git error" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2"],
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to create worktree");
expect(executedCommands).toEqual([
"git rev-parse --is-inside-work-tree",
"command -v tmux",
"command -v opencode",
"opencode models",
"tmux has-session -t test-session",
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2",
"git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2",
]);
});
test("model name collision creates unique window names", async () => {
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2\nanthropic/gpt-5.2",
stderr: "",
};
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[
"git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: ["openai/gpt-5.2", "anthropic/gpt-5.2"],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Use `tmux attach -t test-session` to join session",
);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"),
),
).toBe(true);
expect(
executedCommands.some((c) =>
c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"),
),
).toBe(true);
});
test("long model name is truncated in window name", async () => {
const longModel = "verylongmodelfrontexampleprovider";
mockCommandResponses["opencode models"] = {
ok: true,
stdout: longModel,
stderr: "",
};
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`
] = { ok: false, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: "test-session",
models: [longModel],
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Use `tmux attach -t test-session` to join session",
);
const windowName = executedCommands
.find((c) => c.includes("tmux new-session"))
?.match(/-n (\S+)/)?.[1];
expect(windowName?.length).toBeLessThanOrEqual(24);
});
});
// ----------
// Core utils tests remain in a separate file.
+539
View File
@@ -0,0 +1,539 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import { openMultiModel } from "../src/core/open";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const cmd = parts.join(" ");
executedCommands.push(cmd);
const response = mockCommandResponses[cmd] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
quiet: () => ({
nothrow: async () => ({
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
}),
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("openMultiModel error paths", () => {
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
mockCommandResponses = {};
executedCommands = [];
setupMockBun$();
});
afterEach(() => {
restoreOriginalBun$();
});
test("fails when model list command fails", async () => {
const session = "sess";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: false,
stdout: "",
stderr: "model error",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to load valid models");
});
test("fails when duplicate models provided", async () => {
const session = "dup";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2", "openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Duplicate model names are not allowed");
});
test("fails when session already exists", async () => {
const session = "existing";
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: "openai/gpt-5.2",
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models: ["openai/gpt-5.2"],
binaryName: "opencode",
mode: "cli",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Session already exists");
});
test("second model fails because branch already exists", async () => {
const session = "sess-add";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
// common mocks
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model branch exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: true, stdout: "", stderr: "" };
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2 (Branch");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree creation fails", async () => {
const session = "sess-wt";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model fails worktree
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: false, stdout: "", stderr: "wt error" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model window creation fails", async () => {
const session = "sess-win";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model window failure
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
] = { ok: false, stdout: "", stderr: "win err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain("Failed: anthropic/gpt-5-2");
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model launch command fails", async () => {
const session = "sess-launch";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model launch fails
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${secondBranch} ${secondPath}`] =
{ ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux new-window -d -t ${session} -n gpt-5-2-2 -c ${secondPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2-2 ${binary} --model 'anthropic/gpt-5-2' C-m`
] = { ok: false, stdout: "", stderr: "launch err" };
mockCommandResponses[`git worktree remove -f ${secondPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[`git branch -D ${secondBranch}`] = {
ok: true,
stdout: "",
stderr: "",
};
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain(
"Failed: anthropic/gpt-5-2 (launch err",
);
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
test("second model worktree path already exists", async () => {
const session = "sess-wtpath";
const binary = "opencode";
const models = ["openai/gpt-5.2", "anthropic/gpt-5-2"];
mockCommandResponses["git rev-parse --is-inside-work-tree"] = {
ok: true,
stdout: "true",
stderr: "",
};
mockCommandResponses["command -v tmux"] = {
ok: true,
stdout: "tmux",
stderr: "",
};
mockCommandResponses["command -v opencode"] = {
ok: true,
stdout: "opencode",
stderr: "",
};
mockCommandResponses["opencode models"] = {
ok: true,
stdout: models.join("\n"),
stderr: "",
};
mockCommandResponses[`tmux has-session -t ${session}`] = {
ok: false,
stdout: "",
stderr: "",
};
// first model success
const firstBranch = `opencode/${session}/gpt-5-2`;
const firstPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${firstBranch}`
] = { ok: false, stdout: "", stderr: "" };
mockCommandResponses[`git worktree add -b ${firstBranch} ${firstPath}`] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses[
`tmux new-session -d -s ${session} -n gpt-5-2 -c ${firstPath}`
] = { ok: true, stdout: "", stderr: "" };
mockCommandResponses[
`tmux send-keys -t ${session}:gpt-5-2 ${binary} --model 'openai/gpt-5.2' C-m`
] = { ok: true, stdout: "", stderr: "" };
// second model path exists
const secondBranch = `opencode/${session}/gpt-5-2-2`;
const secondPath = `/mock/home/.local/share/opencode/multi-model/${session}/gpt-5-2-2`;
mockCommandResponses[
`git show-ref --verify --quiet refs/heads/${secondBranch}`
] = { ok: false, stdout: "", stderr: "" };
// mock fs existsSync to return true for secondPath
spyOn(fs, "existsSync").mockImplementation((p) => p === secondPath);
const result = await openMultiModel({
sessionName: session,
models,
binaryName: binary,
mode: "cli",
});
expect(result.success).toBe(true);
expect(result.instructions).toContain("Error: Created tmux session");
expect(result.instructions).toContain(
"Failed: anthropic/gpt-5-2 (Worktree path already exists at",
);
expect(result.windows).toContain("gpt-5-2");
expect(result.windows).toContain("gpt-5-2-2");
});
});
+487
View File
@@ -0,0 +1,487 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
import * as os from "node:os";
import {
createWindowBaseName,
createWindowPlans,
findDuplicates,
formatInvalidModelError,
getBinaryName,
getSessionPath,
getWorktreePath,
getWorktreesForSession,
launchModelInWindow,
levenshtein,
normalizeModels,
runCommand,
sanitizeName,
shellQuote,
suggestModels,
undoWorktree,
} from "../src/core/utils";
const originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$;
let mockCommandResponses: Record<
string,
{ ok: boolean; stdout: string; stderr: string }
> = {};
let executedCommands: string[] = [];
function setupMockBun$() {
const mockFn = mock((_strings: TemplateStringsArray, ...values: any[]) => {
const parts = values[0] as string[];
const commandSignature = parts.join(" ");
executedCommands.push(commandSignature);
return {
quiet: () => ({
nothrow: async () => {
const response = mockCommandResponses[commandSignature] ?? {
ok: true,
stdout: "",
stderr: "",
};
return {
exitCode: response.ok ? 0 : 1,
stdout: { toString: () => response.stdout },
stderr: { toString: () => response.stderr },
};
},
}),
};
});
(globalThis as any).Bun.$ = mockFn;
return mockFn;
}
function restoreOriginalBun$() {
(globalThis as any).Bun.$ = originalBun$;
}
describe("core utils", () => {
let _bunMock: ReturnType<typeof setupMockBun$>;
beforeEach(() => {
spyOn(os, "homedir").mockReturnValue("/mock/home");
_bunMock = setupMockBun$();
executedCommands = [];
mockCommandResponses = {};
});
afterEach(() => {
restoreOriginalBun$();
});
describe("shellQuote", () => {
test("normal string without special characters", () => {
expect(shellQuote("test")).toBe("'test'");
});
test("string with spaces", () => {
expect(shellQuote("test value")).toBe("'test value'");
});
test("string with single quotes", () => {
expect(shellQuote("test'value")).toBe("'test'\"'\"'value'");
});
test("string with multiple single quotes", () => {
expect(shellQuote("te's't'v'alue")).toBe(
"'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'",
);
});
test("empty string", () => {
expect(shellQuote("")).toBe("''");
});
});
describe("normalizeModels", () => {
test("empty array", () => {
expect(normalizeModels([])).toEqual([]);
});
test("undefined input", () => {
expect(normalizeModels(undefined)).toEqual([]);
});
test("array with whitespace-only strings", () => {
expect(normalizeModels([" ", " "])).toEqual([]);
});
test("array with mixed valid models, padded models, and empty strings", () => {
expect(
normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "]),
).toEqual(["openai/gpt-5.2", "anthropic/claude"]);
});
});
describe("findDuplicates", () => {
test("array with no duplicates", () => {
expect(findDuplicates(["a", "b", "c"])).toEqual([]);
});
test("array with one duplicate pair", () => {
expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]);
});
test("array with multiple different duplicates", () => {
expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]);
});
test("array with a single value repeated more than twice", () => {
expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]);
});
test("verifying first-repeated-occurrence order", () => {
expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual([
"a",
"x",
"b",
]);
});
});
describe("createWindowBaseName", () => {
test("normal model name without slashes", () => {
expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2");
});
test("model with slashes", () => {
expect(createWindowBaseName("vendor/namespace/model")).toBe("model");
});
test("model exceeding WINDOW_NAME_LIMIT", () => {
const longName = "a".repeat(30);
expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa");
});
test("model with special characters", () => {
expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0");
});
test("model made entirely of special characters", () => {
expect(createWindowBaseName("!@#$%^&*()")).toBe("model");
});
});
describe("createWindowPlans", () => {
test("single model", () => {
expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
]);
});
test("two models with the same base name", () => {
expect(
createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"]),
).toEqual([
{ model: "openai/gpt-5-2", windowName: "gpt-5-2" },
{ model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" },
]);
});
test("multiple collisions", () => {
const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]);
expect(result[0]?.windowName).toBe("a");
expect(result[3]?.windowName).toBe("a-2");
expect(result[5]?.windowName).toBe("a-3");
});
test("truncation to accommodate suffix", () => {
const longBase = "a".repeat(23);
const result = createWindowPlans([longBase, "b"]);
expect(result[1]?.windowName.length).toBeLessThanOrEqual(24);
});
});
describe("levenshtein", () => {
test("identical strings", () => {
expect(levenshtein("test", "test")).toBe(0);
});
test("one substitution", () => {
expect(levenshtein("test", "tent")).toBe(1);
});
test("one insertion", () => {
expect(levenshtein("test", "tests")).toBe(1);
});
test("one deletion", () => {
expect(levenshtein("tests", "test")).toBe(1);
});
test("completely different strings", () => {
expect(levenshtein("abc", "xyz")).toBe(3);
});
test("one empty string", () => {
expect(levenshtein("", "test")).toBe(4);
});
test("both empty strings", () => {
expect(levenshtein("", "")).toBe(0);
});
});
describe("suggestModels", () => {
test("exact match", () => {
const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]);
expect(result[0]).toBe("gpt-5.2");
});
test("fuzzy match/typo", () => {
expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]);
});
test("fuzzy match with contains boost", () => {
const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]);
expect(suggestions[0]).toBe("gpt-5.2");
});
test("maximum of 3 suggestions", () => {
const allowlist = [
"gpt-5.2",
"gpt-5.2-mini",
"gpt-5.2-pro",
"gpt-5.2-ultra",
];
expect(suggestModels("gpt4o", allowlist).length).toBe(3);
});
test("sorting logic", () => {
const suggestions = suggestModels("abc", ["abc", "abd", "abe"]);
expect(suggestions).toEqual(["abc", "abd", "abe"]);
});
});
describe("formatInvalidModelError", () => {
test("single invalid model with available suggestions", () => {
const error = formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-5.2"]);
expect(error).toContain("Model name 'gpt5.4' not found");
expect(error).toContain("Did you mean");
});
test("single invalid model without close matches", () => {
const error = formatInvalidModelError(["xyz"], ["abc", "def"]);
expect(error).toContain("Model name 'xyz' not found");
expect(error).toContain("Did you mean");
});
test("multiple invalid models", () => {
const error = formatInvalidModelError(["xyz", "abc"], ["def"]);
expect(error).toContain("Model names not found");
expect(error).toContain("'xyz'");
expect(error).toContain("'abc'");
});
});
describe("sanitizeName", () => {
test("normal alphanumeric name", () => {
expect(sanitizeName("test-session")).toBe("test-session");
});
test("name with spaces and special chars", () => {
expect(sanitizeName("test session!")).toBe("test-session");
});
test("name with consecutive hyphens", () => {
expect(sanitizeName("test--session")).toBe("test-session");
});
test("name with leading/trailing hyphens", () => {
expect(sanitizeName("-test-session-")).toBe("test-session");
});
});
describe("runCommand", () => {
test("returns ok true for successful command", async () => {
mockCommandResponses["echo hello"] = {
ok: true,
stdout: "hello",
stderr: "",
};
const result = await runCommand(["echo", "hello"]);
expect(result.ok).toBe(true);
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
});
test("returns ok false for failed command", async () => {
mockCommandResponses.false = {
ok: false,
stdout: "",
stderr: "error",
};
const result = await runCommand(["false"]);
expect(result.ok).toBe(false);
expect(result.stderr).toBe("error");
});
});
describe("getWorktreePath", () => {
test("returns correct worktree path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("my-session", "gpt-5.2");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
});
test("handles nested session names", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getWorktreePath("parent/child", "window");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/parent/child/window",
);
});
});
describe("getSessionPath", () => {
test("returns correct session path", () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
const result = getSessionPath("my-session");
expect(result).toBe(
"/home/user/.local/share/opencode/multi-model/my-session",
);
});
});
describe("undoWorktree", () => {
test("returns empty array on success", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: true,
stdout: "",
stderr: "",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors).toEqual([]);
});
test("returns error when worktree remove fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: false,
stdout: "",
stderr: "remove failed",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: true,
stdout: "",
stderr: "",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to remove worktree");
});
test("returns error when branch delete fails", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: true,
stdout: "",
stderr: "",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: false,
stdout: "",
stderr: "branch delete failed",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Failed to delete branch");
});
test("returns both errors when both operations fail", async () => {
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
ok: false,
stdout: "",
stderr: "remove failed",
};
mockCommandResponses["git branch -D opencode/session/window"] = {
ok: false,
stdout: "",
stderr: "branch delete failed",
};
const errors = await undoWorktree(
"/path/to/worktree",
"opencode/session/window",
);
expect(errors.length).toBe(2);
});
});
describe("launchModelInWindow", () => {
test("sends correct tmux command", async () => {
mockCommandResponses[
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"
] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow("session", {
model: "openai/gpt-5.2",
windowName: "window",
});
expect(result.ok).toBe(true);
expect(executedCommands).toContain(
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m",
);
});
test("uses custom binary name", async () => {
mockCommandResponses[
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"
] = { ok: true, stdout: "", stderr: "" };
const result = await launchModelInWindow(
"session",
{ model: "openai/gpt-5.2", windowName: "window" },
"kilo",
);
expect(result.ok).toBe(true);
expect(executedCommands).toContain(
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m",
);
});
});
describe("getWorktreesForSession", () => {
test("returns session-specific worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout:
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2");
});
test("sanitizes session name when filtering worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout:
"worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2",
stderr: "",
};
const worktrees = await getWorktreesForSession("my session!");
expect(worktrees.length).toBe(1);
expect(worktrees[0]?.path).toBe(
"/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2",
);
});
test("returns empty array when no matching worktrees", async () => {
spyOn(os, "homedir").mockReturnValue("/home/user");
mockCommandResponses["git worktree list --porcelain"] = {
ok: true,
stdout: "worktree /home/user/other-repo\nbranch refs/heads/main",
stderr: "",
};
const worktrees = await getWorktreesForSession("my-session");
expect(worktrees).toEqual([]);
});
});
describe("getBinaryName", () => {
test("returns default 'opencode' when env var not set", () => {
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
expect(getBinaryName()).toBe("opencode");
});
test("returns env var value when set", () => {
process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo";
expect(getBinaryName()).toBe("kilo");
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
});
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": ["bun-types"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}