fix(team-mode): keep tmux visualization in caller window

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-06 17:17:23 +09:00
parent 675f7880fe
commit b4791c85ca
3 changed files with 102 additions and 101 deletions
@@ -114,7 +114,7 @@ describe("team-layout-tmux", () => {
return null
}
return { sessionId: displaySessionId }
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
})
})
@@ -149,7 +149,7 @@ describe("team-layout-tmux", () => {
expect(runTmuxCommandMock).toHaveBeenCalledTimes(0)
})
test("creates detached focus and grid windows and sends attach via send-keys", async () => {
test("creates teammate panes in the caller window and sends attach via send-keys", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -162,12 +162,8 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const newWindowCalls = commands.filter((args) => args[0] === "new-window")
expect(newWindowCalls.length).toBe(2)
expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([
"team-run-attach-focus",
"team-run-attach-grid",
])
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2)
const sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
const literals = sendKeysCalls.map((args) => args.join(" "))
@@ -175,7 +171,7 @@ describe("team-layout-tmux", () => {
expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true)
})
test("uses focus main-vertical and grid tiled windows", async () => {
test("uses caller window main-vertical layout with caller pane as primary", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -191,14 +187,14 @@ describe("team-layout-tmux", () => {
const commands = getCommands()
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical")
expect(selectLayoutArgs).toContain("tiled")
expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"])
expect(selectLayoutArgs).not.toContain("tiled")
expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"])
expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
})
test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => {
test("#given 4 or more teammates #when createTeamLayout runs #then it keeps every teammate in the caller window", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = Array.from({ length: 5 }, (_, index) => ({
@@ -212,13 +208,11 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const newWindowNames = commands
.filter((args) => args[0] === "new-window")
.map((args) => args[args.indexOf("-n") + 1])
expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"])
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5)
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical")
expect(selectLayoutArgs).toContain("tiled")
expect(selectLayoutArgs).not.toContain("tiled")
})
test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => {
@@ -333,7 +327,7 @@ describe("team-layout-tmux", () => {
})
describe("createTeamLayout - focus/grid window topology", () => {
test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => {
test("#given caller inside tmux #when createTeamLayout runs #then uses the caller window without a new session", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -347,8 +341,8 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
expect(commands.some((args) => args[0] === "new-session")).toBe(false)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(false)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(true)
})
test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => {
@@ -364,7 +358,7 @@ describe("team-layout-tmux", () => {
expect(result?.ownedSession).toBe(false)
})
test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => {
test("#given first teammate #when layout runs #then it splits the caller pane horizontally for teammate area", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
@@ -375,8 +369,10 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const splitCalls = commands.filter((args) => args[0] === "split-window")
expect(splitCalls).toEqual([])
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(splitCalls).toEqual([
["split-window", "-t", process.env.TMUX_PANE ?? "", "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", "/tmp/m1"],
])
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
})
test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => {
@@ -397,7 +393,7 @@ describe("team-layout-tmux", () => {
expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3)
})
test("#given layout created #when createTeamLayout runs #then it keeps separate focus and grid pane maps", async () => {
test("#given layout created #when createTeamLayout runs #then it records focus panes only", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -412,9 +408,10 @@ describe("team-layout-tmux", () => {
const commands = getCommands()
expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
expect(result?.focusWindowId).not.toBe(result?.gridWindowId)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
expect(result?.focusWindowId).toBe("test-session:0")
expect(result?.gridWindowId).toBeUndefined()
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true)
})
})
@@ -9,7 +9,7 @@ type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string
export type TeamLayoutResult = {
focusWindowId: string
gridWindowId: string
gridWindowId?: string
focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string>
targetSessionId: string
@@ -34,60 +34,63 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
}
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> {
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"])
async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
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 createTeamWindow(
function selectExistingTeammatePane(teammatePanes: Array<string>, callerPaneId: string): string {
return teammatePanes[Math.floor(teammatePanes.length / 2)] ?? teammatePanes[teammatePanes.length - 1] ?? callerPaneId
}
function buildSplitArgs(callerPaneId: string, teammatePanes: Array<string>, member: TeamLayoutMember): Array<string> {
if (teammatePanes.length === 0) {
return ["split-window", "-t", callerPaneId, "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member)]
}
return [
"split-window",
"-t",
selectExistingTeammatePane(teammatePanes, callerPaneId),
teammatePanes.length % 2 === 1 ? "-v" : "-h",
"-P",
"-F",
"#{pane_id}",
"-c",
getPaneWorkingDirectory(member),
]
}
async function createTeamLayoutInCallerWindow(
tmuxPath: string,
targetSessionId: string,
windowName: string,
layout: "main-vertical" | "tiled",
callerPaneId: string,
windowTarget: string,
members: Array<TeamLayoutMember>,
serverUrl: string,
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
const [firstMember, ...restMembers] = members
if (!firstMember) return null
const created = await runTmuxCommand(tmuxPath, [
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName,
"-c", getPaneWorkingDirectory(firstMember),
])
if (!created.success || !created.output) return null
const windowId = created.output.trim()
const initialPanes = await listPanesInWindow(tmuxPath, windowId)
const firstPaneId = initialPanes[0]
if (!firstPaneId) return null
const panesByMember: Record<string, string> = { [firstMember.name]: firstPaneId }
for (const member of restMembers) {
const split = await runTmuxCommand(tmuxPath, [
"split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId,
"-c", getPaneWorkingDirectory(member),
])
if (!split.success || !split.output) return null
panesByMember[member.name] = split.output.trim()
}
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
if (!layoutResult.success) return null
if (layout === "main-vertical") {
await runTmuxCommand(tmuxPath, ["set-window-option", "-t", windowId, "main-pane-width", "60%"])
await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
}
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
const panesByMember: Record<string, string> = {}
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
for (const member of members) {
const paneId = panesByMember[member.name]
if (!paneId) return null
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
if (!split.success || !split.output) return null
const paneId = split.output.trim()
teammatePanes = [...teammatePanes, paneId]
panesByMember[member.name] = paneId
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
}
return { windowId, panesByMember }
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
if (!layoutResult.success) return null
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
if (!resizeResult.success) return null
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
}
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
@@ -111,27 +114,21 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
}
const callerSession = await resolveCallerTmuxSession(tmuxPath)
const fallbackSessionName = `omo-team-${teamRunId}`
const ownedSession = callerSession === null
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
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 (!callerSession) {
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
return null
}
const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl)
const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl)
if (!focus || !grid) return null
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
if (!focus) return null
return {
focusWindowId: focus.windowId,
gridWindowId: grid.windowId,
focusPanesByMember: focus.panesByMember,
gridPanesByMember: grid.panesByMember,
targetSessionId,
ownedSession,
focusWindowId: focus.focusWindowId,
gridWindowId: undefined,
focusPanesByMember: focus.focusPanesByMember,
gridPanesByMember: {},
targetSessionId: callerSession.sessionId,
ownedSession: false,
}
} catch (error) {
log("tmux visualization unavailable, skipping", { error: String(error) })
@@ -23,7 +23,7 @@ type TmuxManagerLike = {
type TeamLayoutResultLike = {
focusWindowId: string
gridWindowId: string
gridWindowId?: string
focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string>
targetSessionId: string
@@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike {
}
return typeof value.focusWindowId === "string"
&& typeof value.gridWindowId === "string"
&& (value.gridWindowId === undefined || typeof value.gridWindowId === "string")
&& isRecord(value.focusPanesByMember)
&& isRecord(value.gridPanesByMember)
&& typeof value.targetSessionId === "string"
@@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout(
targetSessionId,
focusWindowId: layoutResult.focusWindowId,
gridWindowId: layoutResult.gridWindowId,
paneIds: Object.values(layoutResult.focusPanesByMember),
},
tmuxManager,
]))
@@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => {
await rm(state.tempRoot, { recursive: true, force: true })
})
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => {
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then teammate panes appear in the caller window and cleanup leaves the session intact", async () => {
// given
const state = requireLiveTestState()
const layoutModule = await loadLayoutModule()
const teamRunId = randomUUID()
const shortTeamRunId = teamRunId.slice(0, 8)
const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`]
const initialWindows = await listWindows(state.callerSessionId)
const members: TeamLayoutMemberLike[] = [
{
@@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => {
// when
const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager)
const windowsAppeared = await waitForCondition(async () => {
const panesAppeared = await waitForCondition(async () => {
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => panes.stdout.split("\n").includes(paneId))
})
const windowsUnchangedBeforeCleanup = await waitForCondition(async () => {
const windows = await listWindows(state.callerSessionId)
return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName))
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
})
await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId)
const windowsRemoved = await waitForCondition(async () => {
const panesRemoved = await waitForCondition(async () => {
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => !panes.stdout.split("\n").includes(paneId))
})
const windowsUnchangedAfterCleanup = await waitForCondition(async () => {
const windows = await listWindows(state.callerSessionId)
const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName))
const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
return noExpectedWindowsRemain && sameWindowIds
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
})
const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId])
// then
expect(layoutResult.focusWindowId.length).toBeGreaterThan(0)
expect(layoutResult.gridWindowId.length).toBeGreaterThan(0)
expect(windowsAppeared).toBe(true)
expect(windowsRemoved).toBe(true)
expect(layoutResult.gridWindowId).toBeUndefined()
expect(panesAppeared).toBe(true)
expect(windowsUnchangedBeforeCleanup).toBe(true)
expect(panesRemoved).toBe(true)
expect(windowsUnchangedAfterCleanup).toBe(true)
expect(callerSessionStillAlive.success).toBe(true)
expect(process.env.TMUX_PANE).toBe(state.callerPaneId)
})