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 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) 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = [ const members = [
@@ -162,12 +162,8 @@ describe("team-layout-tmux", () => {
// then // then
const commands = getCommands() const commands = getCommands()
const newWindowCalls = commands.filter((args) => args[0] === "new-window") expect(commands.some((args) => args[0] === "new-window")).toBe(false)
expect(newWindowCalls.length).toBe(2) expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2)
expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([
"team-run-attach-focus",
"team-run-attach-grid",
])
const sendKeysCalls = commands.filter((args) => args[0] === "send-keys") const sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
const literals = sendKeysCalls.map((args) => args.join(" ")) 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) 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = [ const members = [
@@ -191,14 +187,14 @@ describe("team-layout-tmux", () => {
const commands = getCommands() const commands = getCommands()
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical") expect(selectLayoutArgs).toContain("main-vertical")
expect(selectLayoutArgs).toContain("tiled") expect(selectLayoutArgs).not.toContain("tiled")
expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"]) expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"])
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = Array.from({ length: 5 }, (_, index) => ({ const members = Array.from({ length: 5 }, (_, index) => ({
@@ -212,13 +208,11 @@ describe("team-layout-tmux", () => {
// then // then
const commands = getCommands() const commands = getCommands()
const newWindowNames = commands expect(commands.some((args) => args[0] === "new-window")).toBe(false)
.filter((args) => args[0] === "new-window") expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5)
.map((args) => args[args.indexOf("-n") + 1])
expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"])
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical") 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 () => { 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", () => { 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = [ const members = [
@@ -347,8 +341,8 @@ describe("team-layout-tmux", () => {
// then // then
const commands = getCommands() const commands = getCommands()
expect(commands.some((args) => args[0] === "new-session")).toBe(false) expect(commands.some((args) => args[0] === "new-session")).toBe(false)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) 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(false) 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 () => { 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) 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }] const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
@@ -375,8 +369,10 @@ describe("team-layout-tmux", () => {
// then // then
const commands = getCommands() const commands = getCommands()
const splitCalls = commands.filter((args) => args[0] === "split-window") const splitCalls = commands.filter((args) => args[0] === "split-window")
expect(splitCalls).toEqual([]) expect(splitCalls).toEqual([
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) ["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 () => { 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) 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 // given
const { createTeamLayout } = await loadLayoutModule() const { createTeamLayout } = await loadLayoutModule()
const members = [ const members = [
@@ -412,9 +408,10 @@ describe("team-layout-tmux", () => {
const commands = getCommands() const commands = getCommands()
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
expect(result?.focusWindowId).not.toBe(result?.gridWindowId) expect(result?.focusWindowId).toBe("test-session:0")
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) 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) 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 = { export type TeamLayoutResult = {
focusWindowId: string focusWindowId: string
gridWindowId: string gridWindowId?: string
focusPanesByMember: Record<string, string> focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string> gridPanesByMember: Record<string, string>
targetSessionId: 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))}` return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
} }
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> { async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"]) const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
if (!result.success || !result.output) return [] if (!result.success || !result.output) return []
return result.output.trim().split("\n").filter(Boolean) 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, tmuxPath: string,
targetSessionId: string, callerPaneId: string,
windowName: string, windowTarget: string,
layout: "main-vertical" | "tiled",
members: Array<TeamLayoutMember>, members: Array<TeamLayoutMember>,
serverUrl: string, serverUrl: string,
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> { ): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
const [firstMember, ...restMembers] = members const panesByMember: Record<string, string> = {}
if (!firstMember) return null const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
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])
}
for (const member of members) { for (const member of members) {
const paneId = panesByMember[member.name] const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
if (!paneId) return null 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, ["select-pane", "-t", paneId, "-T", member.name])
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"]) 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> { 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 callerSession = await resolveCallerTmuxSession(tmuxPath)
const fallbackSessionName = `omo-team-${teamRunId}` if (!callerSession) {
const ownedSession = callerSession === null log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName 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
} }
const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl) const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl) if (!focus) return null
if (!focus || !grid) return null
return { return {
focusWindowId: focus.windowId, focusWindowId: focus.focusWindowId,
gridWindowId: grid.windowId, gridWindowId: undefined,
focusPanesByMember: focus.panesByMember, focusPanesByMember: focus.focusPanesByMember,
gridPanesByMember: grid.panesByMember, gridPanesByMember: {},
targetSessionId, targetSessionId: callerSession.sessionId,
ownedSession, ownedSession: false,
} }
} catch (error) { } catch (error) {
log("tmux visualization unavailable, skipping", { error: String(error) }) log("tmux visualization unavailable, skipping", { error: String(error) })
@@ -23,7 +23,7 @@ type TmuxManagerLike = {
type TeamLayoutResultLike = { type TeamLayoutResultLike = {
focusWindowId: string focusWindowId: string
gridWindowId: string gridWindowId?: string
focusPanesByMember: Record<string, string> focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string> gridPanesByMember: Record<string, string>
targetSessionId: string targetSessionId: string
@@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike {
} }
return typeof value.focusWindowId === "string" return typeof value.focusWindowId === "string"
&& typeof value.gridWindowId === "string" && (value.gridWindowId === undefined || typeof value.gridWindowId === "string")
&& isRecord(value.focusPanesByMember) && isRecord(value.focusPanesByMember)
&& isRecord(value.gridPanesByMember) && isRecord(value.gridPanesByMember)
&& typeof value.targetSessionId === "string" && typeof value.targetSessionId === "string"
@@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout(
targetSessionId, targetSessionId,
focusWindowId: layoutResult.focusWindowId, focusWindowId: layoutResult.focusWindowId,
gridWindowId: layoutResult.gridWindowId, gridWindowId: layoutResult.gridWindowId,
paneIds: Object.values(layoutResult.focusPanesByMember),
}, },
tmuxManager, tmuxManager,
])) ]))
@@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => {
await rm(state.tempRoot, { recursive: true, force: true }) 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 // given
const state = requireLiveTestState() const state = requireLiveTestState()
const layoutModule = await loadLayoutModule() const layoutModule = await loadLayoutModule()
const teamRunId = randomUUID() const teamRunId = randomUUID()
const shortTeamRunId = teamRunId.slice(0, 8)
const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`]
const initialWindows = await listWindows(state.callerSessionId) const initialWindows = await listWindows(state.callerSessionId)
const members: TeamLayoutMemberLike[] = [ const members: TeamLayoutMemberLike[] = [
{ {
@@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => {
// when // when
const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager) 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) 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) 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 windows = await listWindows(state.callerSessionId)
const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName)) return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
return noExpectedWindowsRemain && sameWindowIds
}) })
const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId]) const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId])
// then // then
expect(layoutResult.focusWindowId.length).toBeGreaterThan(0) expect(layoutResult.focusWindowId.length).toBeGreaterThan(0)
expect(layoutResult.gridWindowId.length).toBeGreaterThan(0) expect(layoutResult.gridWindowId).toBeUndefined()
expect(windowsAppeared).toBe(true) expect(panesAppeared).toBe(true)
expect(windowsRemoved).toBe(true) expect(windowsUnchangedBeforeCleanup).toBe(true)
expect(panesRemoved).toBe(true)
expect(windowsUnchangedAfterCleanup).toBe(true)
expect(callerSessionStillAlive.success).toBe(true) expect(callerSessionStillAlive.success).toBe(true)
expect(process.env.TMUX_PANE).toBe(state.callerPaneId) expect(process.env.TMUX_PANE).toBe(state.callerPaneId)
}) })