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
+}