From c5df781029f207d48d02cc63e4ae422b8eb9a474 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:32 +0900 Subject: [PATCH] feat(team-mode): add tmux team layout creation and removal --- .../team-mode/team-layout-tmux/layout.test.ts | 390 +++++++++++++++--- .../team-mode/team-layout-tmux/layout.ts | 282 +++++++++---- 2 files changed, 548 insertions(+), 124 deletions(-) diff --git a/src/features/team-mode/team-layout-tmux/layout.test.ts b/src/features/team-mode/team-layout-tmux/layout.test.ts index aa9a90ff5..33dccaeb2 100644 --- a/src/features/team-mode/team-layout-tmux/layout.test.ts +++ b/src/features/team-mode/team-layout-tmux/layout.test.ts @@ -1,94 +1,378 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" +/// -type LayoutModule = typeof import("./layout") +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" -const spawnMock = mock(() => ({ - exited: Promise.resolve(0), - stdout: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("%1\n")); controller.close() } }), - stderr: new ReadableStream({ start(controller) { controller.close() } }), -})) +import * as sharedModule from "../../../shared" +import * as sharedTmuxModule from "../../../shared/tmux" +import * as tmuxPathResolverModule from "../../../tools/interactive-bash/tmux-path-resolver" +import * as resolveCallerTmuxSessionModule from "./resolve-caller-tmux-session" +import { canVisualize, createTeamLayout, removeTeamLayout } from "./layout" -const layoutSpecifier = import.meta.resolve("./layout") -const spawnProcessSpecifier = import.meta.resolve("../../../shared/tmux/tmux-utils/spawn-process") -const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver") -const sharedSpecifier = import.meta.resolve("../../../shared") +let nextWindowNumber = 1 +let nextPaneNumber = 1 +let displaySessionId = "$7" +let displaySuccess = true -function registerModuleMocks(): void { - mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock })) - mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: mock(() => Promise.resolve("tmux")) })) - mock.module(sharedSpecifier, () => ({ log: mock(() => undefined) })) +function createTmuxCommandResult(output: string, success = true) { + return { + success, + output, + stdout: output, + stderr: success ? "" : "error", + exitCode: success ? 0 : 1, + } } -async function loadLayoutModule(): Promise { - const module = await import(`${layoutSpecifier}?test=${crypto.randomUUID()}`) - return module as LayoutModule +const runTmuxCommandMock = mock((_tmuxPath: string, args: Array, _options?: unknown) => { + const command = args[0] + + if (command === "display" && args.includes("#{session_name}:#{window_index}")) { + return Promise.resolve(createTmuxCommandResult("test-session:0")) + } + + if (command === "display" && args.includes("#{window_id}")) { + return Promise.resolve(createTmuxCommandResult("@1")) + } + + if (command === "display" && args.includes("#{pane_current_command}")) { + return Promise.resolve(createTmuxCommandResult("fish")) + } + + if (command === "display") { + return Promise.resolve(createTmuxCommandResult(displaySessionId, displaySuccess)) + } + + if (command === "list-panes") { + const allPanes = [process.env.TMUX_PANE ?? "%0"] + for (let i = 1; i < nextPaneNumber; i++) allPanes.push(`%${i}`) + return Promise.resolve(createTmuxCommandResult(allPanes.join("\n"))) + } + + if (command === "new-session") { + return Promise.resolve(createTmuxCommandResult(`@${nextWindowNumber++}`)) + } + + if (command === "split-window") { + return Promise.resolve(createTmuxCommandResult(`%${nextPaneNumber++}`)) + } + + return Promise.resolve(createTmuxCommandResult("")) +}) + +const isServerRunningMock = mock(async (_serverUrl: string) => true) + +async function loadLayoutModule() { + return { canVisualize, createTeamLayout, removeTeamLayout } +} + +type TmuxMgrLike = { getServerUrl: () => string } + +const tmuxMgr: TmuxMgrLike = { getServerUrl: () => "http://127.0.0.1:12345" } + +function getCommands(): Array> { + return Array.from(runTmuxCommandMock.mock.calls, (call) => call[1]) } describe("team-layout-tmux", () => { + afterEach(() => { + mock.restore() + }) + beforeEach(() => { - registerModuleMocks() - spawnMock.mockClear() + runTmuxCommandMock.mockClear() + isServerRunningMock.mockClear() + isServerRunningMock.mockImplementation(async () => true) + nextWindowNumber = 1 + nextPaneNumber = 1 + displaySessionId = "$7" + displaySuccess = true process.env.TMUX = "/tmp/tmux-1" + process.env.TMUX_PANE = "%42" + spyOn(tmuxPathResolverModule, "getTmuxPath").mockResolvedValue("tmux") + spyOn(sharedModule, "log").mockImplementation(() => undefined) + spyOn(sharedTmuxModule, "isServerRunning").mockImplementation(isServerRunningMock) + spyOn(sharedTmuxModule, "runTmuxCommand").mockImplementation(runTmuxCommandMock) + spyOn(resolveCallerTmuxSessionModule, "resolveCallerTmuxSession").mockImplementation(async () => { + if (!process.env.TMUX_PANE || !displaySuccess || !/^\$[0-9]+$/.test(displaySessionId)) { + return null + } + + return { sessionId: displaySessionId } + }) }) test("returns null and makes no tmux calls when visualization unavailable", async () => { // given delete process.env.TMUX - const { createTeamLayout, canVisualize } = await loadLayoutModule() + const { canVisualize, createTeamLayout } = await loadLayoutModule() // when - const result = await createTeamLayout("run-1", [], {} as never) + const result = await createTeamLayout("run-1", [], tmuxMgr as never) // then expect(canVisualize()).toBe(false) expect(result).toBeNull() - expect(spawnMock).toHaveBeenCalledTimes(0) + expect(runTmuxCommandMock).toHaveBeenCalledTimes(0) }) - test("creates focus and grid windows", async () => { + test("returns null when server health check fails", async () => { // given + isServerRunningMock.mockImplementation(async () => false) const { createTeamLayout } = await loadLayoutModule() - const members = [ - { name: "lead", sessionId: "s1", color: "red" }, - { name: "m2", sessionId: "s2" }, - { name: "m3", sessionId: "s3" }, - ] // when - await createTeamLayout("run-2", members, {} as never) - - // then - expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("new-session") - expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("new-window") - expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("split-window") - expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("select-layout") - expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("select-pane") - }) - - test("returns null when tmux command fails", async () => { - // given - const { createTeamLayout } = await loadLayoutModule() - spawnMock.mockImplementationOnce(() => ({ - exited: Promise.resolve(1), - stdout: new ReadableStream({ start(controller) { controller.close() } }), - stderr: new ReadableStream({ start(controller) { controller.close() } }), - })) - - // when - const result = await createTeamLayout("run-3", [{ name: "lead", sessionId: "s1" }], {} as never) + const result = await createTeamLayout( + "run-health", + [{ name: "lead", sessionId: "s1", worktreePath: "/tmp/lead" }], + tmuxMgr as never, + ) // then expect(result).toBeNull() + expect(runTmuxCommandMock).toHaveBeenCalledTimes(0) }) - test("cleans up the tmux session", async () => { + test("splits current window for each member and sends attach via send-keys", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + ] + + // when + await createTeamLayout("run-attach", members, tmuxMgr as never) + + // then + const commands = getCommands() + const splitCalls = commands.filter((args) => args[0] === "split-window") + expect(splitCalls.length).toBe(2) + expect(commands.some((args) => args[0] === "new-window")).toBe(false) + + const sendKeysCalls = commands.filter((args) => args[0] === "send-keys") + const literals = sendKeysCalls.map((args) => args.join(" ")) + expect(literals.some((s) => s.includes("--session 's-m1'"))).toBe(true) + expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true) + }) + + test("uses main-vertical layout with leader at 30%", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + { name: "m3", sessionId: "s-m3", worktreePath: "/tmp/m3" }, + ] + + // when + const result = await createTeamLayout("run-layout", members, tmuxMgr as never) + + // then + const commands = getCommands() + const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) + expect(selectLayoutArgs.every((l) => l === "main-vertical")).toBe(true) + const resizeCalls = commands.filter((args) => args[0] === "resize-pane" && args.includes("30%")) + expect(resizeCalls.length).toBeGreaterThan(0) + expect(result).not.toBeNull() + expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) + }) + + test("sets pane title for each member", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "lead", sessionId: "s-lead", worktreePath: "/tmp/lead" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + ] + + // when + await createTeamLayout("run-title", members, tmuxMgr as never) + + // then + const commands = getCommands() + const titleSetters = commands + .filter((args) => args[0] === "select-pane" && args.includes("-T")) + .map((args) => args[args.length - 1]) + expect(titleSetters).toContain("lead") + expect(titleSetters).toContain("m2") + }) + + test("#given ownedSession=false, focusWindowId=@10, gridWindowId=@11 #when removeTeamLayout runs #then tmux kill-window is called twice with -t @10 and -t @11 and kill-session is NEVER called", async () => { // given const { removeTeamLayout } = await loadLayoutModule() // when - await removeTeamLayout("run-4", {} as never) + await removeTeamLayout("run-cleanup", { + ownedSession: false, + targetSessionId: "$caller", + focusWindowId: "@10", + gridWindowId: "@11", + }, tmuxMgr as never) // then - expect(spawnMock.mock.calls.some((call) => (call[0] as Array).includes("kill-session"))).toBe(true) + const commands = getCommands() + expect(commands).toContainEqual(["kill-window", "-t", "@10"]) + expect(commands).toContainEqual(["kill-window", "-t", "@11"]) + expect(commands.some((args) => args[0] === "kill-session")).toBe(false) + }) + + test("#given ownedSession=true, targetSessionId='omo-team-xyz' #when removeTeamLayout runs #then kill-session is called with -t omo-team-xyz (legacy behavior preserved)", async () => { + // given + const { removeTeamLayout } = await loadLayoutModule() + + // when + await removeTeamLayout("run-cleanup", { + ownedSession: true, + targetSessionId: "omo-team-xyz", + focusWindowId: "@10", + gridWindowId: "@11", + }, tmuxMgr as never) + + // then + const commands = getCommands() + expect(commands).toContainEqual(["kill-session", "-t", "omo-team-xyz"]) + }) + + test("#given ownedSession=false and the first kill-window fails #when removeTeamLayout runs #then the second kill-window still fires", async () => { + // given + const { removeTeamLayout } = await loadLayoutModule() + let killWindowCallCount = 0 + runTmuxCommandMock.mockImplementation((_tmuxPath: string, args: Array, _options?: unknown) => { + if (args[0] === "kill-window") { + killWindowCallCount += 1 + return Promise.resolve(createTmuxCommandResult("", killWindowCallCount > 1)) + } + + const command = args[0] + if (command === "display") { + return Promise.resolve(createTmuxCommandResult(displaySessionId, displaySuccess)) + } + if (command === "new-session") { + return Promise.resolve(createTmuxCommandResult(`@${nextWindowNumber++}`)) + } + if (command === "new-window") { + return Promise.resolve(createTmuxCommandResult(`@${nextWindowNumber++} %${nextPaneNumber++}`)) + } + if (command === "split-window") { + return Promise.resolve(createTmuxCommandResult(`%${nextPaneNumber++}`)) + } + + return Promise.resolve(createTmuxCommandResult("")) + }) + + // when + await removeTeamLayout("run-cleanup", { + ownedSession: false, + targetSessionId: "$caller", + focusWindowId: "@10", + gridWindowId: "@11", + }, tmuxMgr as never) + + // then + const commands = getCommands().filter((args) => args[0] === "kill-window") + expect(commands).toEqual([ + ["kill-window", "-t", "@10"], + ["kill-window", "-t", "@11"], + ]) + }) + + test("skips all panes when lead member missing", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members: Array<{ name: string; sessionId: string }> = [] + + // when + const result = await createTeamLayout("run-empty", members, tmuxMgr as never) + + // then + expect(result).toBeNull() + const commands = getCommands() + expect(commands.some((args) => args[0] === "new-window")).toBe(false) + }) + + describe("createTeamLayout - split-pane topology", () => { + test("#given caller inside tmux #when createTeamLayout runs #then splits current window and never creates new windows or sessions", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + ] + + // when + await createTeamLayout("run-split", members, tmuxMgr as never) + + // then + const commands = getCommands() + expect(commands.some((args) => args[0] === "new-session")).toBe(false) + expect(commands.some((args) => args[0] === "new-window")).toBe(false) + expect(commands.filter((args) => args[0] === "split-window").length).toBe(2) + }) + + test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }] + + // when + const result = await createTeamLayout("run-owned", members, tmuxMgr as never) + + // then + expect(result).not.toBeNull() + expect(result?.ownedSession).toBe(false) + }) + + test("#given first teammate #when split-window runs #then it creates a single teammate pane from the current window", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }] + + // when + await createTeamLayout("run-first", members, tmuxMgr as never) + + // then + const commands = getCommands() + const splitCalls = commands.filter((args) => args[0] === "split-window") + expect(splitCalls.length).toBe(1) + expect(splitCalls[0]!.includes("-d")).toBe(true) + expect(splitCalls[0]!.includes("-P")).toBe(true) + }) + + test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + { name: "m3", sessionId: "s-m3", worktreePath: "/tmp/m3" }, + ] + + // when + const result = await createTeamLayout("run-3-members", members, tmuxMgr as never) + + // then + expect(result).not.toBeNull() + expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) + expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3) + }) + + test("#given layout created #when createTeamLayout runs #then it keeps a single current-window split result", async () => { + // given + const { createTeamLayout } = await loadLayoutModule() + const members = [ + { name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }, + { name: "m2", sessionId: "s-m2", worktreePath: "/tmp/m2" }, + ] + + // when + const result = await createTeamLayout("run-layout", members, tmuxMgr as never) + + // then + const commands = getCommands() + expect(result).not.toBeNull() + expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) + expect(commands.filter((args) => args[0] === "split-window").length).toBe(2) + expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true) + }) }) }) diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts index f414ccbfe..2b76b06e3 100644 --- a/src/features/team-mode/team-layout-tmux/layout.ts +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -1,104 +1,189 @@ -import { spawn } from "../../../shared/tmux/tmux-utils/spawn-process" import { log } from "../../../shared" +import { shellSingleQuote } from "../../../shared/shell-env" +import { isServerRunning, runTmuxCommand } from "../../../shared/tmux" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import { resolveCallerTmuxSession } from "./resolve-caller-tmux-session" -type TeamLayoutMember = { name: string; sessionId: string; color?: string } +type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string } -type TeamLayoutResult = { +export type TeamLayoutResult = { focusWindowId: string gridWindowId: string - panesByMember: Record + focusPanesByMember: Record + gridPanesByMember: Record + targetSessionId: string + ownedSession: boolean } -export function canVisualize(): boolean { - return process.env.TMUX !== undefined +export type TeamLayoutCleanupTarget = { + ownedSession: boolean + targetSessionId: string + focusWindowId?: string + gridWindowId?: string + paneIds?: Array } -async function runTmux(tmuxPath: string, args: Array): Promise<{ success: boolean; output: string }> { - const proc = spawn([tmuxPath, ...args], { stdout: "pipe", stderr: "pipe" }) - const outputPromise = new Response(proc.stdout).text() - const exitCode = await proc.exited - const output = await outputPromise +export function canVisualize(): boolean { return process.env.TMUX !== undefined } - if (exitCode !== 0) { - return { success: false, output: output.trim() } - } - - return { success: true, output: output.trim() } +function getPaneWorkingDirectory(member: TeamLayoutMember): string { + return member.worktreePath ?? process.cwd() } -async function createWindow( +function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string { + return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}` +} + +const PANE_SHELL_INIT_DELAY_MS = 200 + +let paneCreationLock: Promise = Promise.resolve() + +function acquirePaneCreationLock(): Promise<() => void> { + let release: () => void + const newLock = new Promise((resolve) => { release = resolve }) + const previousLock = paneCreationLock + paneCreationLock = newLock + return previousLock.then(() => release!) +} + +async function resolveCurrentWindowTarget(tmuxPath: string, leaderPaneId: string): Promise { + const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-t", leaderPaneId, "#{session_name}:#{window_index}"]) + if (!result.success || !result.output) return null + return result.output.trim() +} + +async function resolveCurrentWindowId(tmuxPath: string, leaderPaneId: string): Promise { + const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-t", leaderPaneId, "#{window_id}"]) + if (!result.success || !result.output) return null + return result.output.trim() +} + +async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise> { + const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"]) + if (!result.success || !result.output) return [] + return result.output.trim().split("\n").filter(Boolean) +} + +async function rebalanceWithLeader(tmuxPath: string, windowTarget: string, leaderPaneId: string): Promise { + const panes = await listPanesInWindow(tmuxPath, windowTarget) + if (panes.length <= 1) return + await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"]) + await runTmuxCommand(tmuxPath, ["resize-pane", "-t", leaderPaneId, "-x", "30%"]) +} + +async function createTeammatePaneInCurrentWindow( tmuxPath: string, - sessionName: string, - windowName: string, - layout: "main-vertical" | "tiled", - members: Array, -): Promise<{ windowId: string; panesByMember: Record } | null> { - const base = await runTmux(tmuxPath, ["new-window", "-d", "-P", "-F", "#{window_id}", "-t", sessionName, "-n", windowName]) - if (!base.success || !base.output) return null + leaderPaneId: string, + windowTarget: string, + member: TeamLayoutMember, +): Promise { + const releaseLock = await acquirePaneCreationLock() + try { + const panes = await listPanesInWindow(tmuxPath, windowTarget) + const isFirstTeammate = panes.length === 1 - const panesByMember: Record = {} - const [lead, ...rest] = members - if (!lead) return null + let splitResult + if (isFirstTeammate) { + splitResult = await runTmuxCommand(tmuxPath, [ + "split-window", "-t", leaderPaneId, "-h", "-l", "70%", "-d", + "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member), + ]) + } else { + const teammatePanes = panes.filter((p) => p !== leaderPaneId) + const teammateCount = teammatePanes.length + const splitVertically = teammateCount % 2 === 1 + const targetIndex = Math.floor((teammateCount - 1) / 2) + const targetPane = teammatePanes[targetIndex] ?? teammatePanes[teammatePanes.length - 1] - const leadPane = await runTmux(tmuxPath, ["list-panes", "-t", `${sessionName}:${base.output}`, "-F", "#{pane_id}"]) - if (!leadPane.success || !leadPane.output) return null - panesByMember[lead.name] = leadPane.output.split("\n")[0] ?? "" + splitResult = await runTmuxCommand(tmuxPath, [ + "split-window", "-t", targetPane!, splitVertically ? "-v" : "-h", "-d", + "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member), + ]) + } - for (const member of rest) { - const split = await runTmux(tmuxPath, ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", panesByMember[lead.name] ?? base.output, "sh", "-c", "cat >/dev/null"]) - if (!split.success || !split.output) return null - panesByMember[member.name] = split.output + if (!splitResult.success || !splitResult.output) return null + const paneId = splitResult.output.trim() + + await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name]) + await runTmuxCommand(tmuxPath, ["set-option", "-p", "-t", paneId, "pane-border-style", "fg=cyan"]) + await runTmuxCommand(tmuxPath, ["set-option", "-p", "-t", paneId, "pane-active-border-style", "fg=cyan"]) + await runTmuxCommand(tmuxPath, ["set-option", "-p", "-t", paneId, "pane-border-format", "#[fg=cyan,bold] #{pane_title} #[default]"]) + + await rebalanceWithLeader(tmuxPath, windowTarget, leaderPaneId) + await new Promise((resolve) => setTimeout(resolve, PANE_SHELL_INIT_DELAY_MS)) + + return paneId + } finally { + releaseLock() } - - const layoutResult = await runTmux(tmuxPath, ["select-layout", "-t", `${sessionName}:${base.output}`, layout]) - if (!layoutResult.success) return null - - for (const member of members) { - const paneId = panesByMember[member.name] - if (!paneId) return null - const label = member.color ? `${member.name} ${member.color}` : member.name - const titleResult = await runTmux(tmuxPath, ["select-pane", "-t", paneId, "-T", label]) - if (!titleResult.success) return null - await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-status", "top"]) - await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-format", `#{pane_title} ${label}`]) - await runTmux(tmuxPath, ["pipe-pane", "-I", "-t", paneId, "cat >/dev/null"]) - } - - return { windowId: base.output, panesByMember } } -export async function createTeamLayout( - teamRunId: string, - members: Array, - tmuxMgr: TmuxSessionManager, -): Promise { +export async function createTeamLayout(teamRunId: string, members: Array, tmuxMgr: TmuxSessionManager): Promise { if (!canVisualize()) { log("tmux visualization unavailable, skipping") return null } + if (members.length === 0) return null try { - void tmuxMgr + const serverUrl = tmuxMgr.getServerUrl() + if (!(await isServerRunning(serverUrl))) { + log("opencode server not reachable, skipping team layout", { serverUrl }) + return null + } + const tmuxPath = await getTmuxPath() if (!tmuxPath) { log("tmux visualization unavailable, skipping") return null } - const sessionName = `omo-team-${teamRunId}` - const created = await runTmux(tmuxPath, ["new-session", "-d", "-s", sessionName, "-P", "-F", "#{window_id}"]) - if (!created.success || !created.output) return null + const callerSession = await resolveCallerTmuxSession(tmuxPath) + const leaderPaneId = process.env.TMUX_PANE + const fallbackSessionName = `omo-team-${teamRunId}` + const ownedSession = callerSession === null || !leaderPaneId + const targetSessionId = callerSession?.sessionId ?? fallbackSessionName - const focus = await createWindow(tmuxPath, sessionName, "focus", "main-vertical", members) - const grid = await createWindow(tmuxPath, sessionName, "grid", "tiled", members) - if (!focus || !grid) return null + if (ownedSession) { + log("falling back to detached team session because caller tmux session could not be resolved", { teamRunId }) + const created = await runTmuxCommand(tmuxPath, ["new-session", "-d", "-s", fallbackSessionName, "-P", "-F", "#{window_id}"]) + if (!created.success || !created.output) return null + } + + if (!leaderPaneId || ownedSession) { + log("no leader pane for split layout, skipping visualization", { teamRunId }) + return null + } + + const windowTarget = await resolveCurrentWindowTarget(tmuxPath, leaderPaneId) + const windowId = await resolveCurrentWindowId(tmuxPath, leaderPaneId) + if (!windowTarget || !windowId) return null + + await runTmuxCommand(tmuxPath, ["set-option", "-w", "-t", windowTarget, "pane-border-status", "top"]) + + const panesByMember: Record = {} + + for (const member of members) { + const paneId = await createTeammatePaneInCurrentWindow(tmuxPath, leaderPaneId, windowTarget, member) + if (paneId) panesByMember[member.name] = paneId + } + + if (Object.keys(panesByMember).length === 0) return null + + for (const member of members) { + const paneId = panesByMember[member.name] + if (!paneId) continue + const cmd = buildAttachCommand(member, serverUrl) + await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, cmd, "Enter"]) + } return { - focusWindowId: focus.windowId, - gridWindowId: grid.windowId, - panesByMember: focus.panesByMember, + focusWindowId: windowId, + gridWindowId: windowId, + focusPanesByMember: panesByMember, + gridPanesByMember: panesByMember, + targetSessionId, + ownedSession, } } catch (error) { log("tmux visualization unavailable, skipping", { error: String(error) }) @@ -106,15 +191,70 @@ export async function createTeamLayout( } } -export async function removeTeamLayout(teamRunId: string, tmuxMgr: TmuxSessionManager): Promise { - void tmuxMgr +export async function removeTeamLayout(teamRunId: string, _tmuxMgr: TmuxSessionManager): Promise +export async function removeTeamLayout( + teamRunId: string, + _cleanupTarget: TeamLayoutCleanupTarget | undefined, + _tmuxMgr: TmuxSessionManager, +): Promise +export async function removeTeamLayout( + teamRunId: string, + tmuxMgrOrCleanupTarget: TmuxSessionManager | TeamLayoutCleanupTarget | undefined, + _tmuxMgr?: TmuxSessionManager, +): Promise { if (!canVisualize()) return - try { const tmuxPath = await getTmuxPath() if (!tmuxPath) return - await runTmux(tmuxPath, ["kill-session", "-t", `omo-team-${teamRunId}`]) - } catch { - return + + const cleanupTarget = isTeamLayoutCleanupTarget(tmuxMgrOrCleanupTarget) + ? tmuxMgrOrCleanupTarget + : undefined + + if (cleanupTarget?.ownedSession !== false) { + await runTmuxCommand(tmuxPath, ["kill-session", "-t", cleanupTarget?.targetSessionId ?? `omo-team-${teamRunId}`]) + return + } + + if (cleanupTarget.paneIds && cleanupTarget.paneIds.length > 0) { + for (const paneId of cleanupTarget.paneIds) { + try { + await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId]) + } catch { + log("tmux team pane cleanup failed", { teamRunId, paneId }) + } + } + + const leaderPaneId = process.env.TMUX_PANE + if (leaderPaneId) { + const windowTarget = await resolveCurrentWindowTarget(tmuxPath, leaderPaneId) + if (windowTarget) await rebalanceWithLeader(tmuxPath, windowTarget, leaderPaneId) + } + return + } + + const leaderPaneId = process.env.TMUX_PANE + const leaderWindowId = leaderPaneId + ? await resolveCurrentWindowId(tmuxPath, leaderPaneId) + : null + + for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) { + if (!windowId) continue + if (leaderWindowId && windowId === leaderWindowId) { + log("tmux team layout skipping kill-window on leader window", { teamRunId, windowId }) + continue + } + try { + await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId]) + } catch (windowError) { + log("tmux team layout window cleanup failed", { teamRunId, windowId, error: String(windowError) }) + } + } + } catch (error) { + log("tmux team layout cleanup failed", { teamRunId, error: String(error) }) } } + +function isTeamLayoutCleanupTarget(value: TmuxSessionManager | TeamLayoutCleanupTarget | undefined): value is TeamLayoutCleanupTarget { + return value !== undefined && "ownedSession" in value && "targetSessionId" in value +}