This commit is contained in:
Harivansh Rathi 2026-03-05 15:55:27 -08:00
parent 863135d429
commit 43337449e3
88 changed files with 18387 additions and 11 deletions

View file

@ -0,0 +1,191 @@
/**
* CMUX Terminal Adapter
*
* Implements the TerminalAdapter interface for CMUX (cmux.dev).
*/
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
export class CmuxAdapter implements TerminalAdapter {
readonly name = "cmux";
detect(): boolean {
// Check for CMUX specific environment variables
return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
}
spawn(options: SpawnOptions): string {
// We use new-split to create a new pane in CMUX.
// CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command
// in one go while also returning the ID in a way we can easily capture for 'isAlive'.
// However, 'new-split' returns the new surface ID.
// Construct the command with environment variables
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command;
// CMUX new-split returns "OK <UUID>"
const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]);
if (splitResult.status !== 0) {
throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`);
}
const output = splitResult.stdout.trim();
if (output.startsWith("OK ")) {
const surfaceId = output.substring(3).trim();
return surfaceId;
}
throw new Error(`cmux new-split returned unexpected output: ${output}`);
}
kill(paneId: string): void {
if (!paneId) return;
try {
// CMUX calls them surfaces
execCommand("cmux", ["close-surface", "--surface", paneId]);
} catch {
// Ignore errors during kill
}
}
isAlive(paneId: string): boolean {
if (!paneId) return false;
try {
// We can use list-pane-surfaces and grep for the ID
// Or just 'identify' if we want to be precise, but list-pane-surfaces is safer
const result = execCommand("cmux", ["list-pane-surfaces"]);
return result.stdout.includes(paneId);
} catch {
return false;
}
}
setTitle(title: string): void {
try {
// rename-tab or rename-workspace?
// Usually agents want to rename their current "tab" or "surface"
execCommand("cmux", ["rename-tab", title]);
} catch {
// Ignore errors
}
}
/**
* CMUX supports spawning separate OS windows
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window.
*/
spawnWindow(options: SpawnOptions): string {
// CMUX new-window returns "OK <UUID>"
const result = execCommand("cmux", ["new-window"]);
if (result.status !== 0) {
throw new Error(`cmux new-window failed with status ${result.status}: ${result.stderr}`);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const windowId = output.substring(3).trim();
// Now we need to run the command in this window.
// Usually new-window creates a default workspace/surface.
// We might need to find the workspace in that window.
// For now, let's just use 'new-workspace' in that window if possible,
// but CMUX commands usually target the current window unless specified.
// Wait a bit for the window to be ready?
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command;
// Target the new window
execCommand("cmux", ["new-workspace", "--window", windowId, "--command", fullCommand]);
if (options.teamName) {
this.setWindowTitle(windowId, options.teamName);
}
return windowId;
}
throw new Error(`cmux new-window returned unexpected output: ${output}`);
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
try {
execCommand("cmux", ["rename-window", "--window", windowId, title]);
} catch {
// Ignore
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId) return;
try {
execCommand("cmux", ["close-window", "--window", windowId]);
} catch {
// Ignore
}
}
/**
* Check if a window is still alive.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId) return false;
try {
const result = execCommand("cmux", ["list-windows"]);
return result.stdout.includes(windowId);
} catch {
return false;
}
}
/**
* Custom CMUX capability: create a workspace for a problem.
* This isn't part of the TerminalAdapter interface but can be used via the adapter.
*/
createProblemWorkspace(title: string, command?: string): string {
const args = ["new-workspace"];
if (command) {
args.push("--command", command);
}
const result = execCommand("cmux", args);
if (result.status !== 0) {
throw new Error(`cmux new-workspace failed: ${result.stderr}`);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const workspaceId = output.substring(3).trim();
execCommand("cmux", ["workspace-action", "--action", "rename", "--title", title, "--workspace", workspaceId]);
return workspaceId;
}
throw new Error(`cmux new-workspace returned unexpected output: ${output}`);
}
}

View file

@ -0,0 +1,300 @@
/**
* iTerm2 Terminal Adapter
*
* Implements the TerminalAdapter interface for iTerm2 terminal emulator.
* Uses AppleScript for all operations.
*/
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
import { spawnSync } from "node:child_process";
/**
* Context needed for iTerm2 spawning (tracks last pane for layout)
*/
export interface Iterm2SpawnContext {
/** ID of the last spawned session, used for layout decisions */
lastSessionId?: string;
}
export class Iterm2Adapter implements TerminalAdapter {
readonly name = "iTerm2";
private spawnContext: Iterm2SpawnContext = {};
detect(): boolean {
return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
}
/**
* Helper to execute AppleScript via stdin to avoid escaping issues with -e
*/
private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync("osascript", ["-"], {
input: script,
encoding: "utf-8",
});
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
status: result.status,
};
}
spawn(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
let script: string;
if (!this.spawnContext.lastSessionId) {
script = `tell application "iTerm2"
tell current session of current window
set newSession to split vertically with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end tell`;
} else {
script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${this.spawnContext.lastSessionId}" then
tell aSession
set newSession to split horizontally with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end if
end repeat
end repeat
end repeat
end tell`;
}
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
}
const sessionId = result.stdout.toString().trim();
this.spawnContext.lastSessionId = sessionId;
return `iterm_${sessionId}`;
}
kill(paneId: string): void {
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
return;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
close aSession
return "Closed"
end if
end repeat
end repeat
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
isAlive(paneId: string): boolean {
if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
return false;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
return "Alive"
end if
end repeat
end repeat
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
setTitle(title: string): void {
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2" to tell current session of current window
set name to "${escapedTitle}"
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
/**
* iTerm2 supports spawning separate OS windows via AppleScript
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window with the given options.
*/
spawnWindow(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
const escapedTitle = windowTitle.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
set newWindow to (create window with default profile)
tell current session of newWindow
-- Set the session name (tab title)
set name to "${escapedTitle}"
-- Set window title via escape sequence (OSC 2)
-- We use double backslashes for AppleScript to emit a single backslash to the shell
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
-- Execute the command
write text "cd '${options.cwd}' && ${escapedCmd}"
return id of newWindow
end tell
end tell`;
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
}
const windowId = result.stdout.toString().trim();
return `iterm_win_${windowId}`;
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
tell current session of aWindow
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
end tell
exit repeat
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
close aWindow
return "Closed"
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return false;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
return "Alive"
end if
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
/**
* Set the spawn context (used to restore state when needed)
*/
setSpawnContext(context: Iterm2SpawnContext): void {
this.spawnContext = context;
}
/**
* Get current spawn context (useful for persisting state)
*/
getSpawnContext(): Iterm2SpawnContext {
return { ...this.spawnContext };
}
}

View file

@ -0,0 +1,123 @@
/**
* Terminal Registry
*
* Manages terminal adapters and provides automatic selection based on
* the current environment.
*/
import { TerminalAdapter } from "../utils/terminal-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { ZellijAdapter } from "./zellij-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { CmuxAdapter } from "./cmux-adapter";
/**
* Available terminal adapters, ordered by priority
*
* Detection order (first match wins):
* 0. CMUX - if CMUX_SOCKET_PATH is set
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*/
const adapters: TerminalAdapter[] = [
new CmuxAdapter(),
new TmuxAdapter(),
new ZellijAdapter(),
new Iterm2Adapter(),
new WezTermAdapter(),
];
/**
* Cached detected adapter
*/
let cachedAdapter: TerminalAdapter | null = null;
/**
* Detect and return the appropriate terminal adapter for the current environment.
*
* Detection order (first match wins):
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*
* @returns The detected terminal adapter, or null if none detected
*/
export function getTerminalAdapter(): TerminalAdapter | null {
if (cachedAdapter) {
return cachedAdapter;
}
for (const adapter of adapters) {
if (adapter.detect()) {
cachedAdapter = adapter;
return adapter;
}
}
return null;
}
/**
* Get a specific terminal adapter by name.
*
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm")
* @returns The adapter instance, or undefined if not found
*/
export function getAdapterByName(name: string): TerminalAdapter | undefined {
return adapters.find(a => a.name === name);
}
/**
* Get all available adapters.
*
* @returns Array of all registered adapters
*/
export function getAllAdapters(): TerminalAdapter[] {
return [...adapters];
}
/**
* Clear the cached adapter (useful for testing or environment changes)
*/
export function clearAdapterCache(): void {
cachedAdapter = null;
}
/**
* Set a specific adapter (useful for testing or forced selection)
*/
export function setAdapter(adapter: TerminalAdapter): void {
cachedAdapter = adapter;
}
/**
* Check if any terminal adapter is available.
*
* @returns true if a terminal adapter was detected
*/
export function hasTerminalAdapter(): boolean {
return getTerminalAdapter() !== null;
}
/**
* Check if the current terminal supports spawning separate OS windows.
*
* @returns true if the detected terminal supports windows (iTerm2, WezTerm)
*/
export function supportsWindows(): boolean {
const adapter = getTerminalAdapter();
return adapter?.supportsWindows() ?? false;
}
/**
* Get the name of the currently detected terminal adapter.
*
* @returns The adapter name, or null if none detected
*/
export function getTerminalName(): string | null {
return getTerminalAdapter()?.name ?? null;
}

View file

@ -0,0 +1,112 @@
/**
* Tmux Terminal Adapter
*
* Implements the TerminalAdapter interface for tmux terminal multiplexer.
*/
import { execSync } from "node:child_process";
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
export class TmuxAdapter implements TerminalAdapter {
readonly name = "tmux";
detect(): boolean {
// tmux is available if TMUX environment variable is set
return !!process.env.TMUX;
}
spawn(options: SpawnOptions): string {
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
const tmuxArgs = [
"split-window",
"-h", "-dP",
"-F", "#{pane_id}",
"-c", options.cwd,
"env", ...envArgs,
"sh", "-c", options.command
];
const result = execCommand("tmux", tmuxArgs);
if (result.status !== 0) {
throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
}
// Apply layout after spawning
execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
execCommand("tmux", ["select-layout", "main-vertical"]);
return result.stdout.trim();
}
kill(paneId: string): void {
if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
return; // Not a tmux pane
}
try {
execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
} catch {
// Ignore errors - pane may already be dead
}
}
isAlive(paneId: string): boolean {
if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
return false; // Not a tmux pane
}
try {
execSync(`tmux has-session -t ${paneId}`);
return true;
} catch {
return false;
}
}
setTitle(title: string): void {
try {
execCommand("tmux", ["select-pane", "-T", title]);
} catch {
// Ignore errors
}
}
/**
* tmux does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error("tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.");
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}

View file

@ -0,0 +1,101 @@
/**
* WezTerm Adapter Tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { WezTermAdapter } from "./wezterm-adapter";
import * as terminalAdapter from "../utils/terminal-adapter";
describe("WezTermAdapter", () => {
let adapter: WezTermAdapter;
let mockExecCommand: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
adapter = new WezTermAdapter();
mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
delete process.env.WEZTERM_PANE;
delete process.env.TMUX;
delete process.env.ZELLIJ;
process.env.WEZTERM_PANE = "0";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("name", () => {
it("should have the correct name", () => {
expect(adapter.name).toBe("WezTerm");
});
});
describe("detect", () => {
it("should detect when WEZTERM_PANE is set", () => {
mockExecCommand.mockReturnValue({ stdout: "version 1.0", stderr: "", status: 0 });
expect(adapter.detect()).toBe(true);
});
});
describe("spawn", () => {
it("should spawn first pane to the right with 50%", () => {
// Mock getPanes finding only current pane
mockExecCommand.mockImplementation((bin, args) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]),
stderr: "",
status: 0
};
}
if (args.includes("split-pane")) {
return { stdout: "1", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "test-agent",
cwd: "/home/user/project",
command: "pi --agent test",
env: { PI_AGENT_ID: "test-123" },
});
expect(result).toBe("wezterm_1");
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"])
);
});
it("should spawn subsequent panes by splitting the sidebar", () => {
// Mock getPanes finding current pane (0) and sidebar pane (1)
mockExecCommand.mockImplementation((bin, args) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }, { pane_id: 1, tab_id: 0 }]),
stderr: "",
status: 0
};
}
if (args.includes("split-pane")) {
return { stdout: "2", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "agent2",
cwd: "/home/user/project",
command: "pi",
env: {},
});
expect(result).toBe("wezterm_2");
// 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50%
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"])
);
});
});
});

View file

@ -0,0 +1,304 @@
/**
* WezTerm Terminal Adapter
*
* Implements the TerminalAdapter interface for WezTerm terminal emulator.
* Uses wezterm cli split-pane for pane management.
*/
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
export class WezTermAdapter implements TerminalAdapter {
readonly name = "WezTerm";
// Common paths where wezterm CLI might be found
private possiblePaths = [
"wezterm", // In PATH
"/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS
"/usr/local/bin/wezterm", // Linux/macOS common
"/usr/bin/wezterm", // Linux system
];
private weztermPath: string | null = null;
private findWeztermBinary(): string | null {
if (this.weztermPath !== null) {
return this.weztermPath;
}
for (const path of this.possiblePaths) {
try {
const result = execCommand(path, ["--version"]);
if (result.status === 0) {
this.weztermPath = path;
return path;
}
} catch {
// Continue to next path
}
}
this.weztermPath = null;
return null;
}
detect(): boolean {
if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) {
return false;
}
return this.findWeztermBinary() !== null;
}
/**
* Get all panes in the current tab to determine layout state.
*/
private getPanes(): any[] {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return [];
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return [];
try {
const allPanes = JSON.parse(result.stdout);
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
// Find the tab of the current pane
const currentPane = allPanes.find((p: any) => p.pane_id === currentPaneId);
if (!currentPane) return [];
// Return all panes in the same tab
return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id);
} catch {
return [];
}
}
spawn(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const panes = this.getPanes();
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
let weztermArgs: string[];
// First pane: split to the right with 50% (matches iTerm2/tmux behavior)
const isFirstPane = panes.length === 1;
if (isFirstPane) {
weztermArgs = [
"cli", "split-pane", "--right", "--percent", "50",
"--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command
];
} else {
// Subsequent teammates stack in the sidebar on the right.
// currentPaneId (id 0) is the main pane on the left.
// All other panes are in the sidebar.
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
const sidebarPanes = panes
.filter(p => p.pane_id !== currentPaneId)
.sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
// To add a new pane to the bottom of the sidebar stack:
// We always split the BOTTOM-MOST pane (sidebarPanes[0])
// and use 50% so the new pane and the previous bottom pane are equal.
// This progressively fills the sidebar from top to bottom.
const targetPane = sidebarPanes[0];
weztermArgs = [
"cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(),
"--percent", "50",
"--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command
];
}
const result = execCommand(weztermBin, weztermArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn failed: ${result.stderr}`);
}
// New: After spawning, tell WezTerm to equalize the panes in this tab
// This ensures that regardless of the split math, they all end up the same height.
try {
execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed
// WezTerm doesn't have a single "equalize" command like tmux,
// but splitting with no percentage usually balances, or we can use
// the 'AdjustPaneSize' sequence.
// For now, let's stick to the 50/50 split of the LAST pane which is most reliable.
} catch {}
const paneId = result.stdout.trim();
return `wezterm_${paneId}`;
}
kill(paneId: string): void {
if (!paneId?.startsWith("wezterm_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermId = paneId.replace("wezterm_", "");
try {
execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]);
} catch {}
}
isAlive(paneId: string): boolean {
if (!paneId?.startsWith("wezterm_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
const panes = this.getPanes();
return panes.some(p => p.pane_id === weztermId);
}
setTitle(title: string): void {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
try {
execCommand(weztermBin, ["cli", "set-tab-title", title]);
} catch {}
}
/**
* WezTerm supports spawning separate OS windows via CLI
*/
supportsWindows(): boolean {
return this.findWeztermBinary() !== null;
}
/**
* Spawn a new separate OS window with the given options.
* Uses `wezterm cli spawn --new-window` and sets the window title.
*/
spawnWindow(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`);
// Format window title as "teamName: agentName" if teamName is provided
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
// Spawn a new window
const spawnArgs = [
"cli", "spawn", "--new-window",
"--cwd", options.cwd,
"--", "env", ...envArgs, "sh", "-c", options.command
];
const result = execCommand(weztermBin, spawnArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn-window failed: ${result.stderr}`);
}
// The output is the pane ID, we need to find the window ID
const paneId = result.stdout.trim();
// Query to get window ID from pane ID
const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10));
// Set the window title if we found the window
if (windowId !== null) {
this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle);
}
return `wezterm_win_${windowId || paneId}`;
}
/**
* Get window ID from a pane ID by querying WezTerm
*/
private getWindowIdFromPaneId(paneId: number): number | null {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return null;
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return null;
try {
const allPanes = JSON.parse(result.stdout);
const pane = allPanes.find((p: any) => p.pane_id === paneId);
return pane?.window_id ?? null;
} catch {
return null;
}
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
execCommand(weztermBin, ["cli", "set-window-title", "--window-id", weztermWindowId, title]);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
// WezTerm doesn't have a direct kill-window command, so we kill all panes in the window
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return;
const allPanes = JSON.parse(result.stdout);
const windowPanes = allPanes.filter((p: any) => p.window_id.toString() === weztermWindowId);
for (const pane of windowPanes) {
execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", pane.pane_id.toString()]);
}
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId?.startsWith("wezterm_win_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return false;
const allPanes = JSON.parse(result.stdout);
return allPanes.some((p: any) => p.window_id.toString() === weztermWindowId);
} catch {
return false;
}
}
}

View file

@ -0,0 +1,97 @@
/**
* Zellij Terminal Adapter
*
* Implements the TerminalAdapter interface for Zellij terminal multiplexer.
* Note: Zellij uses --close-on-exit, so explicit kill is not needed.
*/
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
export class ZellijAdapter implements TerminalAdapter {
readonly name = "zellij";
detect(): boolean {
// Zellij is available if ZELLIJ env is set and not in tmux
return !!process.env.ZELLIJ && !process.env.TMUX;
}
spawn(options: SpawnOptions): string {
const zellijArgs = [
"run",
"--name", options.name,
"--cwd", options.cwd,
"--close-on-exit",
"--",
"env",
...Object.entries(options.env)
.filter(([k]) => k.startsWith("PI_"))
.map(([k, v]) => `${k}=${v}`),
"sh", "-c", options.command
];
const result = execCommand("zellij", zellijArgs);
if (result.status !== 0) {
throw new Error(`zellij spawn failed with status ${result.status}: ${result.stderr}`);
}
// Zellij doesn't return a pane ID, so we create a synthetic one
return `zellij_${options.name}`;
}
kill(_paneId: string): void {
// Zellij uses --close-on-exit, so panes close automatically
// when the process exits. No explicit kill needed.
}
isAlive(paneId: string): boolean {
// Zellij doesn't have a straightforward way to check if a pane is alive
// For now, we assume alive if it's a zellij pane ID
if (!paneId || !paneId.startsWith("zellij_")) {
return false;
}
// Could potentially use `zellij list-sessions` or similar in the future
return true;
}
setTitle(_title: string): void {
// Zellij pane titles are set via --name at spawn time
// No runtime title changing supported
}
/**
* Zellij does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error("Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.");
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}