diff --git a/packages/pi-teams/src/adapters/tmux-adapter.test.ts b/packages/pi-teams/src/adapters/tmux-adapter.test.ts new file mode 100644 index 0000000..1591ef8 --- /dev/null +++ b/packages/pi-teams/src/adapters/tmux-adapter.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as terminalAdapter from "../utils/terminal-adapter"; +import { TmuxAdapter } from "./tmux-adapter"; + +describe("TmuxAdapter", () => { + let adapter: TmuxAdapter; + let mockExecCommand: ReturnType; + + beforeEach(() => { + adapter = new TmuxAdapter(); + mockExecCommand = vi.spyOn(terminalAdapter, "execCommand"); + delete process.env.TMUX; + delete process.env.ZELLIJ; + delete process.env.WEZTERM_PANE; + delete process.env.TERM_PROGRAM; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("detects tmux in headless runtimes when the binary is available", () => { + mockExecCommand.mockReturnValue({ + stdout: "tmux 3.4", + stderr: "", + status: 0, + }); + + expect(adapter.detect()).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith("tmux", ["-V"]); + }); + + it("creates a detached team session when not already inside tmux", () => { + mockExecCommand.mockImplementation((_bin: string, args: string[]) => { + if (args[0] === "has-session") { + return { stdout: "", stderr: "missing", status: 1 }; + } + if (args[0] === "new-session") { + return { stdout: "%1\n", stderr: "", status: 0 }; + } + return { stdout: "", stderr: "", status: 0 }; + }); + + expect( + adapter.spawn({ + name: "worker", + cwd: "/tmp/project", + command: "pi", + env: { PI_TEAM_NAME: "demo", PI_AGENT_NAME: "worker" }, + }), + ).toBe("%1"); + + expect(mockExecCommand).toHaveBeenCalledWith( + "tmux", + expect.arrayContaining(["new-session", "-d", "-s", "pi-teams-demo"]), + ); + }); + + it("checks pane liveness by pane id", () => { + mockExecCommand.mockReturnValue({ + stdout: "%7\n", + stderr: "", + status: 0, + }); + + expect(adapter.isAlive("%7")).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith("tmux", [ + "display-message", + "-p", + "-t", + "%7", + "#{pane_id}", + ]); + }); +}); diff --git a/packages/pi-teams/src/adapters/tmux-adapter.ts b/packages/pi-teams/src/adapters/tmux-adapter.ts index 78496af..ae73b46 100644 --- a/packages/pi-teams/src/adapters/tmux-adapter.ts +++ b/packages/pi-teams/src/adapters/tmux-adapter.ts @@ -4,7 +4,6 @@ * Implements the TerminalAdapter interface for tmux terminal multiplexer. */ -import { execSync } from "node:child_process"; import { execCommand, type SpawnOptions, @@ -15,8 +14,12 @@ export class TmuxAdapter implements TerminalAdapter { readonly name = "tmux"; detect(): boolean { - // tmux is available if TMUX environment variable is set - return !!process.env.TMUX; + if (process.env.TMUX) return true; + if (process.env.ZELLIJ || process.env.TERM_PROGRAM === "iTerm.app") { + return false; + } + if (process.env.WEZTERM_PANE) return false; + return execCommand("tmux", ["-V"]).status === 0; } spawn(options: SpawnOptions): string { @@ -24,12 +27,50 @@ export class TmuxAdapter implements TerminalAdapter { .filter(([k]) => k.startsWith("PI_")) .map(([k, v]) => `${k}=${v}`); + let targetWindow: string | null = null; + if (!process.env.TMUX) { + const sessionName = `pi-teams-${options.env.PI_TEAM_NAME || "default"}`; + targetWindow = `${sessionName}:0`; + const hasSession = execCommand("tmux", [ + "has-session", + "-t", + sessionName, + ]); + if (hasSession.status !== 0) { + const result = execCommand("tmux", [ + "new-session", + "-d", + "-s", + sessionName, + "-P", + "-F", + "#{pane_id}", + "-c", + options.cwd, + "env", + ...envArgs, + "sh", + "-c", + options.command, + ]); + + if (result.status !== 0) { + throw new Error( + `tmux spawn failed with status ${result.status}: ${result.stderr}`, + ); + } + + return result.stdout.trim(); + } + } + const tmuxArgs = [ "split-window", "-h", "-dP", "-F", "#{pane_id}", + ...(targetWindow ? ["-t", targetWindow] : []), "-c", options.cwd, "env", @@ -48,8 +89,17 @@ export class TmuxAdapter implements TerminalAdapter { } // Apply layout after spawning - execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]); - execCommand("tmux", ["select-layout", "main-vertical"]); + execCommand("tmux", [ + "set-window-option", + ...(targetWindow ? ["-t", targetWindow] : []), + "main-pane-width", + "60%", + ]); + execCommand("tmux", [ + "select-layout", + ...(targetWindow ? ["-t", targetWindow] : []), + "main-vertical", + ]); return result.stdout.trim(); } @@ -79,12 +129,14 @@ export class TmuxAdapter implements TerminalAdapter { return false; // Not a tmux pane } - try { - execSync(`tmux has-session -t ${paneId}`); - return true; - } catch { - return false; - } + const result = execCommand("tmux", [ + "display-message", + "-p", + "-t", + paneId.trim(), + "#{pane_id}", + ]); + return result.status === 0 && result.stdout.trim() === paneId.trim(); } setTitle(title: string): void {