mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 22:01:41 +00:00
packages
This commit is contained in:
parent
863135d429
commit
43337449e3
88 changed files with 18387 additions and 11 deletions
191
packages/pi-teams/src/adapters/cmux-adapter.ts
Normal file
191
packages/pi-teams/src/adapters/cmux-adapter.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
300
packages/pi-teams/src/adapters/iterm2-adapter.ts
Normal file
300
packages/pi-teams/src/adapters/iterm2-adapter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
123
packages/pi-teams/src/adapters/terminal-registry.ts
Normal file
123
packages/pi-teams/src/adapters/terminal-registry.ts
Normal 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;
|
||||
}
|
||||
112
packages/pi-teams/src/adapters/tmux-adapter.ts
Normal file
112
packages/pi-teams/src/adapters/tmux-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
packages/pi-teams/src/adapters/wezterm-adapter.test.ts
Normal file
101
packages/pi-teams/src/adapters/wezterm-adapter.test.ts
Normal 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"])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
304
packages/pi-teams/src/adapters/wezterm-adapter.ts
Normal file
304
packages/pi-teams/src/adapters/wezterm-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
packages/pi-teams/src/adapters/zellij-adapter.ts
Normal file
97
packages/pi-teams/src/adapters/zellij-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue